80 Commits
v3.6.2 ... main

Author SHA1 Message Date
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
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
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
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
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
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
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
Jason
74969ae968 fix(dialog): prevent dialogs from closing on overlay click
Add onInteractOutside handler to DialogContent to prevent accidental
dialog closure when users click on the overlay/backdrop. This prevents
data loss in forms and improves user experience across all 11 dialog
components in the application.

Users can still close dialogs using:
- Close button (X) in the top-right corner
- Cancel/Close buttons within the dialog
- ESC key
2025-11-20 23:29:57 +08:00
Jason
1f3627add3 fix(gemini): persist settings json edits 2025-11-20 20:23:22 +08:00
YoVinchen
14ee122b27 feat(settings): add Gemini configuration directory support (#255)
* style: apply code formatting across backend and frontend

Apply cargo fmt and prettier formatting to improve code readability.
No functional changes.

Changes:
- Rust: multi-line assertion formatting (gemini_config, env_checker)
- Rust: simplify chained method calls (provider)
- TypeScript: add trailing commas to function parameters (codexProviderPresets)

* feat(settings): add Gemini configuration directory support

Add custom configuration directory support for Gemini:
- Add geminiConfigDir field to Settings type
- Extend DirectorySettings component with Gemini input
- Update useDirectorySettings hook for Gemini directory management
- Add i18n translations for Gemini directory settings
2025-11-19 21:14:43 +08:00
wugeer
7aecba14fe docs: Add installation instructions for Arch Linux (#259) 2025-11-19 19:12:00 +08:00
Jason
99b5f881e8 docs: add v3.7.0 release documentation
- Update CHANGELOG.md with v3.7.0 entry covering six major features
- Add English release notes (docs/release-note-v3.7.0-en.md)
- Add Chinese release notes (docs/release-note-v3.7.0-zh.md)

Major features documented:
- Gemini CLI integration (third app support)
- MCP v3.7.0 unified architecture
- Claude Skills management system (~2,000 lines)
- Prompts management system (~1,300 lines)
- Deep link protocol (ccswitch://)
- Environment variable conflict detection
2025-11-19 12:39:45 +08:00
Jason
286bafbd67 test: simplify boolean assertions in import_export_sync tests
Replace verbose assert_eq!(value, true) with idiomatic assert!(value) for improved readability and adherence to Rust best practices
2025-11-19 11:45:07 +08:00
Jason
6046cf8767 update screen shots 2025-11-19 11:29:19 +08:00
YoVinchen
c88afa365f style: apply code formatting across backend and frontend (#252)
Apply cargo fmt and prettier formatting to improve code readability.
No functional changes.

Changes:
- Rust: multi-line assertion formatting (gemini_config, env_checker)
- Rust: simplify chained method calls (provider)
- TypeScript: add trailing commas to function parameters (codexProviderPresets)
2025-11-19 11:26:31 +08:00
farion1231
93fa5fe29a chore(release): bump version to v3.7.0 and rebrand to include Gemini CLI
- Update version from 3.6.2 to 3.7.0 across all config files
- Update project description to "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
- Update Chinese description to "Claude Code / Codex / Gemini CLI 全方位辅助工具"
- Sync version in package.json, Cargo.toml, tauri.conf.json
- Update branding in README.md, README_ZH.md and i18n locale files
2025-11-19 11:20:55 +08:00
farion1231
3d31ad64af feat: add DouBaoSeed provider and remove AnyRouter preset
- Add DouBaoSeed code preview provider preset with Volcengine ARK API configuration
- Remove AnyRouter provider from both Claude and Codex preset configurations
- Clean up trailing commas in codex provider presets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:04:44 +08:00
farion1231
bb0951552d feat: update Gemini default model and remove Google Official preset model
Updated default model from gemini-2.5-pro to gemini-3-pro-preview across:
- Provider presets (PackyCode, Custom)
- Form field placeholders
- Default configurations
- Test cases

Google Official preset now has empty env config, allowing users to choose
their own model or use application defaults, which is more appropriate for
OAuth-based authentication.

Changes:
- geminiProviderPresets.ts: updated model to gemini-3-pro-preview, removed model from Google Official
- GeminiFormFields.tsx: updated placeholder to gemini-3-pro-preview
- GeminiConfigSections.tsx: updated placeholder to gemini-3-pro-preview
- ProviderForm.tsx: updated default config to gemini-3-pro-preview
- gemini_config.rs: updated test examples to gemini-3-pro-preview
2025-11-19 10:53:33 +08:00
farion1231
00e3e6fa70 fix: read both .env and settings.json for Gemini live config
Previously, when editing a Gemini provider, only the .env file was read,
missing the settings.json file that contains MCP configuration and other
settings. This caused the config field in the edit form to be empty or
show outdated data.

This fix ensures both files are read and merged into the complete
structure { "env": {...}, "config": {...} }, matching the behavior
of Codex provider.

Fixed in:
- read_live_settings(): now reads both .env and settings.json
- import_default_config(): now reads both files when importing
2025-11-19 10:45:45 +08:00
farion1231
1ce007622e fix: resolve winreg API compatibility issue on Windows
- Update RegValue.to_string() usage to match current winreg API
  which returns String directly instead of Result
- Add conditional compilation for std::fs import (Unix only)
2025-11-19 10:06:52 +08:00
Jason
436f0e8e42 fix: sync Gemini form fields with env editor
The Gemini API key, base URL, and model inputs were not syncing with
the env editor below due to data source mismatch. The form was using
generic hooks (useApiKeyState, useBaseUrlState) that only updated
settingsConfig, while the env editor relied on geminiEnv from
useGeminiConfigState.

Changes:
- Use geminiApiKey/geminiBaseUrl from useGeminiConfigState instead of
  generic hooks
- Wrap handlers to maintain bidirectional sync between geminiEnv and
  settingsConfig
- Remove unused handleGeminiBaseUrlChange from useBaseUrlState to
  avoid naming conflicts

Now all Gemini form fields properly sync with the env editor in both
directions.
2025-11-19 09:28:53 +08:00
YoVinchen
3d69da5b66 feat: add model configuration support and fix Gemini deeplink bug (#251)
* feat(providers): add notes field for provider management

- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field

* chore: format code and clean up unused props

- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs

* feat(deeplink): implement ccswitch:// protocol for provider import

Add deep link support to enable one-click provider configuration import via ccswitch:// URLs.

Backend:
- Implement URL parsing and validation (src-tauri/src/deeplink.rs)
- Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs)
- Register ccswitch:// protocol in macOS Info.plist
- Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs)

Frontend:
- Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx)
- Add API wrapper (src/lib/api/deeplink.ts)
- Integrate event listeners in App.tsx

Configuration:
- Update Tauri config for deep link handling
- Add i18n support for Chinese and English
- Include test page for deep link validation (deeplink-test.html)

Files: 15 changed, 1312 insertions(+)

* chore(deeplink): integrate deep link handling into app lifecycle

Wire up deep link infrastructure with app initialization and event handling.

Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)

Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports

Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies

Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)

Files: 9 changed, 359 insertions(+), 18 deletions(-)

* refactor(deeplink): enhance Codex provider template generation

Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html

* style: fix clippy uninlined_format_args warnings

Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)

* style: apply code formatting and cleanup

- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes

* feat: add model name configuration support for Codex and fix Gemini model handling

- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
2025-11-19 09:03:18 +08:00
Jason
0ae9ed5a17 fix: resolve JSON syntax error in i18n locale files
Fixed missing closing braces in the error object of both en.json and zh.json locale files that caused Vite parse errors. The envManager.error object was not properly closed, causing the subsequent skills object to be parsed incorrectly.
2025-11-19 08:52:26 +08:00
冰子
5ff689af82 Add Gemini environment variable detection (#250)
* feat(env): add environment variable conflict detection and management

实现了系统环境变量冲突检测与管理功能:

核心功能:
- 自动检测会影响 Claude/Codex 的系统环境变量
- 支持 Windows 注册表和 Unix shell 配置文件检测
- 提供可视化的环境变量冲突警告横幅
- 支持批量选择和删除环境变量
- 删除前自动备份,支持后续恢复

技术实现:
- Rust 后端: 跨平台环境变量检测与管理
- React 前端: EnvWarningBanner 组件交互界面
- 国际化支持: 中英文界面
- 类型安全: 完整的 TypeScript 类型定义

* refactor(env): remove unused imports and function

Remove unused HashMap and PathBuf imports, and delete the unused get_source_description function to clean up the code.

* feat: Add Gemini environment variable detection
2025-11-19 08:33:02 +08:00
冰子
b9412ece0b 添加Claude和Codex环境变量检查 (#242)
* feat(env): add environment variable conflict detection and management

实现了系统环境变量冲突检测与管理功能:

核心功能:
- 自动检测会影响 Claude/Codex 的系统环境变量
- 支持 Windows 注册表和 Unix shell 配置文件检测
- 提供可视化的环境变量冲突警告横幅
- 支持批量选择和删除环境变量
- 删除前自动备份,支持后续恢复

技术实现:
- Rust 后端: 跨平台环境变量检测与管理
- React 前端: EnvWarningBanner 组件交互界面
- 国际化支持: 中英文界面
- 类型安全: 完整的 TypeScript 类型定义

* refactor(env): remove unused imports and function

Remove unused HashMap and PathBuf imports, and delete the unused get_source_description function to clean up the code.
2025-11-18 23:44:44 +08:00
YoVinchen
ec303544ca Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature

Implement complete Skills management system with repository discovery,
installation, and lifecycle management capabilities.

Backend:
- Add SkillService with GitHub integration and installation logic
- Implement skill commands (list, install, uninstall, check updates)
- Support multiple skill repositories with caching

Frontend:
- Add Skills management page with repository browser
- Create SkillCard and RepoManager components
- Add badge, card, table UI components
- Integrate Skills API with Tauri commands

Files: 10 files changed, 1488 insertions(+)

* feat(skills): integrate Skills feature into application

Integrate Skills management feature with complete dependency updates,
configuration structure extensions, and internationalization support.

Dependencies:
- Add @radix-ui/react-visually-hidden for accessibility
- Add anyhow, zip, serde_yaml, tempfile for Skills backend
- Enable chrono serde feature for timestamp serialization

Backend Integration:
- Extend MultiAppConfig with SkillStore field
- Implement skills.json migration from legacy location
- Register SkillService and skill commands in main app
- Export skill module in commands and services

Frontend Integration:
- Add Skills page route and dialog in App
- Integrate Skills UI with main navigation

Internationalization:
- Add complete Chinese translations for Skills UI
- Add complete English translations for Skills UI

Code Quality:
- Remove redundant blank lines in gemini_mcp.rs
- Format log statements in mcp.rs

Tests:
- Update import_export_sync tests for SkillStore
- Update mcp_commands tests for new structure

Files: 16 files changed, 540 insertions(+), 39 deletions(-)

* style(skills): improve SkillsPage typography and spacing

Optimize visual hierarchy and readability of Skills page:
- Reduce title size from 2xl to lg with tighter tracking
- Improve description spacing and color contrast
- Enhance empty state with better text hierarchy
- Use explicit gray colors for better dark mode support

* feat(skills): support custom subdirectory path for skill scanning

Add optional skillsPath field to SkillRepo to enable scanning skills
from subdirectories (e.g., "skills/") instead of repository root.

Changes:
- Backend: Add skillsPath field with subdirectory scanning logic
- Frontend: Add skillsPath input field and display in repo list
- Presets: Add cexll/myclaude repo with skills/ subdirectory
- Code quality: Fix clippy warnings (dedup logic, string formatting)

Backward compatible: skillsPath is optional, defaults to root scanning.

* refactor(skills): improve repo manager dialog layout

Optimize dialog structure with fixed header and scrollable content:
- Add flexbox layout with fixed header and scrollable body
- Remove outer border wrapper for cleaner appearance
- Match SkillsPage design pattern for consistency
- Improve UX with better content hierarchy
2025-11-18 22:05:54 +08:00
Jason
023726c59d docs: add Codex MCP raw TOML refactor plan
Add comprehensive implementation plan for v3.7.0 refactor that
separates Codex MCP configuration from unified MCP structure.

Key design decisions:
- Codex MCP stored as raw TOML string in codexMcp.rawToml
- Unified MCP (mcp.servers) only supports Claude/Gemini
- Complete isolation: no apps.codex in unified structure
- Migration clears mcp.codex.servers to prevent pollution

Architecture improvements:
- Single responsibility: each data source has one purpose
- No priority conflicts: completely independent data paths
- Simplified switching logic: no conditional branches
- UI constraints: Tab1 limited to claude/gemini only

Implementation phases:
- Phase 0: Setup (0.5d)
- Phase 1: Backend foundation (1.5d)
- Phase 2: Command layer (1d)
- Phase 3: Switching logic (1d)
- Phase 4: Frontend API (0.5d)
- Phase 5: UI implementation (2d)
- Phase 6: Enhancements (1d, optional)
- Phase 7: Testing & docs (1d)

Total: 8.5 days (MVP: 7 days)

Addresses TOML-JSON conversion data loss issue by preserving
raw TOML format for Codex while maintaining structured approach
for Claude/Gemini.
2025-11-18 16:08:31 +08:00
Jason
8d2c067814 feat(window): center window on screen by default
Add `center: true` to main window configuration to improve initial
window positioning on Windows and other platforms. This addresses
user feedback about the window appearing in the top-left corner.
2025-11-17 23:25:13 +08:00
Jason
a00eb764f7 fix(provider): sync MCP config for all apps on provider switch
Previously, only Codex provider switches triggered MCP synchronization,
which could cause MCP configuration loss when switching Claude or Gemini
providers.

Changes:
- Enable MCP sync for all app types (Claude, Codex, Gemini) during provider switch
- Migrate to v3.7.0 unified MCP sync mechanism using McpService::sync_all_enabled()
- Replace app-specific sync_enabled_to_codex() with unified sync for all apps
- Remove unused mcp module import

This ensures MCP servers remain properly configured across all applications
after provider switches, preventing configuration loss.
2025-11-17 23:17:21 +08:00
Jason
67bd8f5c11 fix(mcp): correct Codex MCP configuration format to [mcp_servers]
BREAKING CHANGE: The [mcp.servers] format was completely incorrect and not
any official Codex format. The only correct format is [mcp_servers] at the
top level of config.toml.

Changes:
- Remove incorrect [mcp.servers] nested table support
- Always use [mcp_servers] top-level table (official Codex format)
- Auto-migrate and cleanup erroneous [mcp.servers] entries on write
- Preserve error-tolerant import for migrating old incorrect configs
- Simplify sync logic by removing format selection branches (~60 lines)
- Update all documentation and tests to reflect correct format
- Add warning logs when detecting and cleaning incorrect format

Backend (Rust):
- mcp.rs: Simplify sync_enabled_to_codex by removing Target enum
- mcp.rs: sync_single_server_to_codex now always uses [mcp_servers]
- mcp.rs: remove_server_from_codex cleans both locations
- mcp.rs: Update import_from_codex comments to clarify format status
- tests: Rename test to sync_enabled_to_codex_migrates_erroneous_*
- tests: Update assertions to verify migration behavior

Frontend (TypeScript):
- tomlUtils.ts: Prioritize [mcp_servers] format in parsing
- tomlUtils.ts: Update error messages to guide correct format

Documentation:
- README.md: Correct MCP format reference to [mcp_servers]
- CLAUDE.md: Add comprehensive format specification with examples

All 79 tests pass. This ensures backward compatibility while enforcing
the correct Codex official standard going forward.

Refs: https://github.com/openai/codex/issues/3441
2025-11-17 22:57:04 +08:00
Jason
3051743bd3 feat(mcp): support extended fields for Codex TOML conversion
## Problem
Previously, the Codex MCP configuration used a whitelist-only approach
for TOML conversion, which caused custom fields (like `timeout`,
`startup_timeout_ms`, etc.) to be silently dropped during JSON → TOML
conversion. Only Claude and Gemini (JSON format) could preserve
arbitrary fields.

## Solution
Implemented a three-tier field handling strategy:

1. **Core fields** (type, command, args, url, headers, env, cwd)
   - Strong-typed manual processing (existing behavior preserved)

2. **Extended fields** (19 common optional fields)
   - White-listed fields with automatic type conversion:
     - General: timeout, timeout_ms, startup_timeout_ms/sec,
       connection_timeout, read_timeout, debug, log_level, disabled
     - stdio: shell, encoding, working_dir, restart_on_exit,
       max_restart_count
     - http/sse: retry_count, max_retry_attempts, retry_delay,
       cache_tools_list, verify_ssl, insecure, proxy

3. **Custom fields** (generic converter)
   - Automatic type inference for:
     - String → TOML String
     - Number (i64/f64) → TOML Integer/Float
     - Boolean → TOML Boolean
     - Simple arrays → TOML Array
     - Shallow objects (string values only) → TOML Inline Table
   - Unsupported types (null, mixed arrays, nested objects) are
     gracefully skipped with debug logging

## Changes

### Core Implementation
- **json_value_to_toml_item()** (mcp.rs:843-947)
  Generic JSON → TOML value converter with smart type inference

- **json_server_to_toml_table()** (mcp.rs:949-1072)
  Refactored to use three-tier strategy, processes all fields beyond
  core whitelist

- **build_servers_table()** (mcp.rs:616-633)
  Now reuses the generic converter for consistency

- **import_from_codex()** (mcp.rs:432-605)
  Extended with generic TOML → JSON converter for bidirectional
  field preservation

### Logging
- DEBUG: Extended field conversions
- INFO: Custom field conversions
- WARN: Skipped unsupported types (with reason)

## Testing
-  All 24 integration tests pass
-  Compilation clean (zero errors)
-  Backward compatible with existing configs

## Design Decision: No Auto Field Mapping
Explicitly NOT implementing automatic field mapping (e.g., Claude's
`timeout` → Codex's `startup_timeout_ms`) due to:
- Unit ambiguity (seconds vs milliseconds)
- Semantic ambiguity (same field name, different meanings)
- Risk of data corruption (30s → 30ms causes immediate timeout)
- Breaks user expectation of "what you see is what you get"

Recommendation: Use application-specific field names as documented.

## Example
User adds `timeout: 30` in MCP panel:

**Claude/Gemini** (~/.claude.json):
```json
{"mcpServers": {"srv": {"timeout": 30}}}
```

**Codex** (~/.codex/config.toml):
```toml
[mcp.servers.srv]
timeout = 30  #  Now preserved!
```

Fixes the whitelist limitation reported in user feedback.
2025-11-17 17:23:09 +08:00
qixing-jk
883cf0346b fix(password input): disable Edge/IE reveal and clear buttons (#232)
Hide default password reveal and clear controls in Microsoft browsers
for improved consistency and security.
2025-11-17 15:30:46 +08:00
Jason
1805ed586e fix(mcp): improve format/submit UX and fix validation errors
Fixes two critical issues with the MCP JSON input:
1. Format button failed with wrapped format like "server": {...}
2. Submit button failed despite input validation passing

Changes:
- Updated formatJSON to use smart parser (supports wrapped format)
- Simplified submit validation logic (removed redundant validateJsonConfig call)
- Improved UX: input preserves original format, cleanup happens on format/submit
  * Prevents confusing "instant disappearance" when pasting wrapped JSON
  * Auto-fills ID/Name fields while keeping input unchanged
  * Format button now strips wrapper key and formats cleanly
  * Submit button correctly extracts config regardless of format

Code quality:
- Reduced code by 5 lines (20 changes, 11 insertions, 16 deletions)
- Consistent use of parseSmartMcpJson across all JSON operations
- No type errors introduced
2025-11-16 20:40:16 +08:00
Jason
98a1305684 feat(mcp): add smart JSON parser for flexible input formats
Support multiple MCP configuration input formats:
- Pure config object: { "command": "npx", ... }
- Key-value pair fragment: "server-name": { "command": "npx", ... }
- Wrapped object: { "server-name": { "command": "npx", ... } }

The parser automatically:
- Detects and wraps JSON fragments into complete objects
- Extracts server name from single-key objects
- Auto-fills ID and Name fields when applicable
- Formats the config for display

This improves UX by allowing users to paste configs directly from
.claude.json or .codex/config.toml without manual editing.
2025-11-16 20:08:04 +08:00
Jason
f79efb86cd refactor(mcp): improve form label layout and simplify text
- Simplify config label from "Full JSON configuration or use" to "Full JSON Configuration"
- Align wizard button to the right using justify-between layout
- Apply same pattern to TOML configuration label
- Improve visual balance with cleaner left-right alignment
2025-11-16 16:59:23 +08:00
Jason
ed59420a83 feat(mcp): enhance form UX with default apps and JSON formatter
- Enable all apps (Claude, Codex, Gemini) by default when adding MCP servers
- Improve config label with clearer wording: "Full JSON configuration or use [Config Wizard]"
- Add JSON format button to beautify configuration with 2-space indentation
- Update tests to reflect new default behavior
- Clean up redundant explicit prop passing

This provides a more streamlined experience by enabling all apps out of the box
and making it easier to format JSON configurations.
2025-11-16 16:50:07 +08:00
Jason
bfc27349b3 feat(mcp): add SSE (Server-Sent Events) transport type support
Add comprehensive support for SSE transport type to MCP server configuration,
enabling real-time streaming connections alongside existing stdio and http types.

Backend Changes:
- Add SSE type validation in mcp.rs validate_server_spec()
- Extend Codex TOML import/export to handle SSE servers
- Update claude_mcp.rs legacy API for backward compatibility
- Unify http/sse handling in json_server_to_toml_table()

Frontend Changes:
- Extend McpServerSpec type definition to include "sse"
- Add SSE radio button to configuration wizard UI
- Update wizard form logic to handle SSE url and headers
- Add SSE validation in McpFormModal submission

Validation & Error Handling:
- Add SSE support in useMcpValidation hook (TOML/JSON)
- Extend tomlUtils normalizeServerConfig for SSE parsing
- Update Zod schemas (common.ts, mcp.ts) with SSE enum
- Add SSE error message mapping in errorUtils

Internationalization:
- Add "typeSse" translations (zh: "sse", en: "sse")

Tests:
- Add SSE validation test cases in useMcpValidation.test.tsx

SSE Configuration Format:
{
  "type": "sse",
  "url": "https://api.example.com/sse",
  "headers": { "Authorization": "Bearer token" }
}
2025-11-16 16:15:17 +08:00
Jason
4fc7413ffa refactor(codex): simplify custom template with minimal config
**Changes:**
- Remove all comments from custom template (align with Claude/Gemini)
- Remove base_url field from template (user fills in form instead)
- Simplify getCodexCustomTemplate() - no locale parameter needed
- Keep preset configurations unchanged with their baseUrl values

**Template now contains only:**
- model_provider, model, model_reasoning_effort
- disable_response_storage
- [model_providers.custom] section with minimal fields

**Benefits:**
-  Cleaner, more focused template
-  Consistent with other apps (no comments)
-  Forces users to fill base_url via form field
-  Reduced template size from 35+ lines to 12 lines

Net change: -74 lines (codexTemplates.ts)
2025-11-16 13:36:33 +08:00
Jason
12112e9d7d refactor(codex): extract template to config with i18n support
**Changes:**
- Create src/config/codexTemplates.ts with getCodexCustomTemplate factory
- Support both Chinese and English templates based on i18n.language
- Remove 70 lines of duplicated template strings from ProviderForm.tsx
- Update both useEffect and handlePresetChange to use template factory
- Clean up unused "Custom (Blank Template)" preset entry

**Benefits:**
-  Eliminates code duplication (35-line template repeated twice)
-  Adds internationalization support for English users
-  Follows project architecture (templates in config/ directory)
-  Improves maintainability (single source of truth)
-  Net reduction: 34 lines (81 additions, 115 deletions)

**Technical Details:**
- Template selection logic: (i18n.language || "zh").startsWith("zh") ? "zh" : "en"
- Templates are identical except for comments language
- Both auth and config are returned as a single CodexTemplate object

Addresses DRY principle violation and architectural concerns identified
in code review.
2025-11-16 13:23:53 +08:00
Jason
6a6980c82c refactor(codex): remove configuration wizard and unify provider setup experience
- Remove CodexQuickWizardModal component (~300 lines)
- Add "Custom (Blank Template)" preset with annotated TOML template
- Unify configuration experience across Claude/Codex/Gemini
- Remove wizard-related i18n keys, keep apiUrlLabel for CodexFormFields
- Simplify component integration by removing wizard state management

This change reduces code complexity by ~250 lines while providing better
user education through commented configuration templates in Chinese.

Users can now:
1. Select "Custom (Blank Template)" preset
2. See annotated TOML template with inline documentation
3. Follow step-by-step comments to configure custom providers

BREAKING CHANGE: Configuration wizard UI removed, replaced with template-based approach
2025-11-16 12:29:18 +08:00
Jason
031ea3a58f style(mcp): refine panel layout for better visual hierarchy and compactness
- Replace checkboxes with toggle switches for app selection (more intuitive for enable/disable actions)
- Change switch color from blue to emerald to match MCP button theme
- Stack app options vertically with labels on left to save horizontal space
- Reduce panel width from max-w-4xl to max-w-3xl for more compact design
- Move docs button next to server name for better information grouping
2025-11-16 11:31:27 +08:00
Jason
9d431cc7ae style(ui): migrate color scheme to macOS native design system
- Update primary blue from Linear style (#3498db) to macOS system blue (#0A84FF)
- Align gray scale with macOS dark mode palette (#1C1C1E, #2C2C2E, etc.)
- Adjust dark mode background to match macOS systemBackground (HSL 240 5% 12%)
- Refine scrollbar colors to use native macOS gray tones
- Remove transparent titleBarStyle for better stability
2025-11-16 10:44:15 +08:00
Jason
685a1138e4 refactor(mcp): complete form refactoring for unified MCP management
Complete the v3.7.0 MCP refactoring by updating the form layer to match
the unified architecture already implemented in data/service/API layers.

**Breaking Changes:**
- Remove confusing `appId` parameter from McpFormModal
- Replace with `defaultFormat` (json/toml) and `defaultEnabledApps` (array)

**Form Enhancements:**
- Add app enablement checkboxes (Claude/Codex/Gemini) directly in the form
- Smart defaults: new servers default to Claude enabled, editing preserves state
- Support "draft" mode: servers can be created without enabling any apps

**Architecture Improvements:**
- Eliminate semantic confusion: format selection separate from app targeting
- One-step workflow: configure and enable apps in single form submission
- Consistent with unified backend: `apps: { claude, codex, gemini }`

**Testing:**
- Update test mocks to use `useUpsertMcpServer` hook
- Add test case for creating servers with no apps enabled
- Fix parameter references from `appId` to `defaultFormat`

**i18n:**
- Add `mcp.form.enabledApps` translation (zh/en)
- Add `mcp.form.noAppsWarning` translation (zh/en)

This completes the MCP management refactoring, ensuring all layers
(data, service, API, UI) follow the same unified architecture pattern.
2025-11-15 23:47:35 +08:00
Jason
154ff4c819 feat(config): unify common config snippets persistence across all apps
- Add unified `common_config_snippets` structure to MultiAppConfig
- Implement `get_common_config_snippet` and `set_common_config_snippet` commands
- Replace localStorage with config.json persistence for Codex and Gemini
- Auto-migrate legacy `claude_common_config_snippet` to new unified structure
- Deprecate individual API methods in favor of unified interface
- Add automatic migration from localStorage on first load

BREAKING CHANGE: Common config snippets now stored in unified `common_config_snippets` object instead of separate fields
2025-11-15 19:52:49 +08:00
Jason
2540f6ba08 refactor(prompt): optimize auto-import logic with unified save
- Change auto_import_prompt_if_exists return type to Result<bool>
- Remove redundant save() calls by introducing updated flag
- Eliminate unnecessary clone() in app type iterations
- Remove semantic contradiction in let _ = ...? pattern
- Improve code semantics and maintainability

Performance: Reduce disk I/O by 50%+ through unified save logic
2025-11-15 16:47:08 +08:00
YoVinchen
d32ceb9b80 fix(prompt): correct live file backfill priority in enable flow (#225)
Fix backfill logic in prompt enable workflow:
- Prioritize backfilling live file content to currently enabled prompt (prevent data loss)
- Create backup only when no enabled prompt exists and content is new (avoid duplicate backups)
- Implement staged persistence (save after backfill + save after enable)
- Add explicit logging for backfill/backup operations

Also simplify string formatting in prompt_files.rs with inline format strings.
2025-11-15 16:09:00 +08:00
Jason
e11c7d84cd test(mcp): update import tests for v3.7.0 unified structure
- Fix import_from_claude_merges_into_config: check unified mcp.servers
- Fix import_from_codex_adds_servers_from_mcp_servers_table: verify apps.codex enabled
- Fix import_from_codex_merges_into_existing_entries: test smart merge preserves existing config
- Replace cc_switch_lib::app_config:: with public exports (McpServer, McpApps)

All 24 import_export_sync tests now passing.
2025-11-14 23:39:34 +08:00
Jason
ea8f2095e2 fix(mcp): migrate import functions to unified v3.7.0 structure
- Rewrite import_from_claude/codex/gemini to write directly to mcp.servers
- Implement skip-on-error strategy for fault tolerance (single invalid item no longer aborts entire batch)
- Smart merge logic: existing servers only enable corresponding app, preserve other configs
- Remove deprecated markers from service layer
- Export McpApps type for test usage
- Update mcp_commands tests to use unified structure

Fixes runtime import issue where data was written to legacy structure (mcp.claude/codex.servers)
but unified panel reads from new structure (mcp.servers), causing "imported but invisible" bug.
2025-11-14 23:33:54 +08:00
Jason
09f80d82bc fix(mcp): initialize McpRoot with v3.7.0 unified structure by default
Problem:
- On first launch, McpRoot::default() created `servers: None`
- McpService::get_all_servers() would incorrectly return error "old structure detected"
- This confused new users who had no legacy MCP data

Root Cause:
- Derived Default trait sets Option<T> fields to None
- New installations should start with v3.7.0 structure immediately

Solution:
- Explicitly implement Default for McpRoot
- Initialize `servers: Some(HashMap::new())` for v3.7.0+ structure
- Legacy fields (claude/codex/gemini) remain empty, only used for deserializing old configs

Impact:
- First-time users get correct v3.7.0 structure immediately
- migrate_mcp_to_unified() correctly detects already-migrated state
- No false "old structure" errors on fresh installs
2025-11-14 22:55:46 +08:00
Jason
2f18d6ec00 refactor(mcp): complete v3.7.0 cleanup - remove legacy code and warnings
This commit finalizes the v3.7.0 unified MCP architecture migration by
removing all deprecated code paths and eliminating compiler warnings.

Frontend Changes (~950 lines removed):
- Remove deprecated components: McpPanel, McpListItem, McpToggle
- Remove deprecated hook: useMcpActions
- Remove unused API methods: importFrom*, syncEnabledTo*, syncAllServers
- Simplify McpFormModal by removing dual-mode logic (unified/legacy)
- Remove syncOtherSide checkbox and conflict detection
- Clean up unused imports and state variables
- Delete associated test files

Backend Changes (~400 lines cleaned):
- Remove unused Tauri commands: import_mcp_from_*, sync_enabled_mcp_to_*
- Delete unused Gemini MCP functions: get_mcp_status, upsert/delete_mcp_server
- Add #[allow(deprecated)] to compatibility layer commands
- Add #[allow(dead_code)] to legacy helper functions for future migration
- Simplify boolean expression in mcp.rs per Clippy suggestion

API Deprecation:
- Mark legacy APIs with @deprecated JSDoc (getConfig, upsertServerInConfig, etc.)
- Preserve backward compatibility for v3.x, planned removal in v4.0

Verification:
-  Zero TypeScript errors (pnpm typecheck)
-  Zero Clippy warnings (cargo clippy)
-  All code formatted (prettier + cargo fmt)
-  Builds successfully

Total cleanup: ~1,350 lines of code removed/marked
Breaking changes: None (all legacy APIs still functional)
2025-11-14 22:43:25 +08:00
Jason
fafca841cb refactor(frontend): remove redundant 'Sync All' button from MCP panel
All MCP operations already auto-sync to live configs:
- upsert_server() → sync_server_to_apps()
- toggle_app() → sync_server_to_app() or remove_server_from_app()
- delete_server() → remove_server_from_all_apps()

The manual 'Sync All' button was redundant and could confuse users
into thinking they need to manually sync after each change.

Changes:
- Remove 'Sync All' button from UnifiedMcpPanel header
- Remove useSyncAllMcpServers hook
- Remove handleSyncAll function and syncAllMutation state
- Remove RefreshCw icon import
- Remove sync-related i18n translations (en/zh)

Note: Backend sync_all_mcp_servers command remains for potential
future use (e.g., recovery tool), but is no longer exposed in UI.
2025-11-14 15:52:01 +08:00
Jason
f4b8aed29a refactor(frontend): remove MCP import functionality for v3.7.0
Auto-migration at startup is sufficient for upgrading from v3.6.x.
Manual import adds unnecessary complexity since:
- Gemini MCP support launches with v3.7.0 (no legacy data)
- Existing Claude/Codex MCP configs are auto-migrated on first run
- All MCP management should happen within CC Switch

Changes:
- Remove McpImportDialog component
- Remove "Import" button from UnifiedMcpPanel
- Remove import-related i18n translations (en/zh)
- Simplify user experience with single management interface

Note: Backend import commands (import_mcp_from_*) remain for
backward compatibility but are no longer exposed in UI.
2025-11-14 15:47:04 +08:00
Jason
9663b4251e feat(frontend): add MCP import dialog for v3.7.0
Implement import functionality to migrate MCP servers from existing configs:

**New Component:**
- src/components/mcp/McpImportDialog.tsx: Import dialog with card-based source selection

**Features:**
- Import from Claude (~/.claude/claude.json or settings.json)
- Import from Codex (~/.codex/config.toml)
- Import from Gemini (config file)
- Card-based UI with icons and descriptions
- Loading states with spinner animation
- Auto-refresh after successful import

**Integration:**
- Add import button to UnifiedMcpPanel header
- Handle import completion with refetch
- Toast notifications for success/info/error cases

**I18n:**
- Add mcp.unifiedPanel.import namespace (zh/en)
- Import button, dialog title, descriptions
- Success/error messages with count interpolation

**UX:**
- Smart disable: other sources disabled during import
- Clear feedback: count of imported servers
- Friendly messages: "No servers found" when empty

TypeScript type check passes 
2025-11-14 15:29:16 +08:00
Jason
9e8abf5f26 feat(frontend): implement unified MCP panel for v3.7.0
Complete Phase 3 (P0) frontend implementation for unified MCP management:

**New Files:**
- src/hooks/useMcp.ts: React Query hooks for unified MCP operations
- src/components/mcp/UnifiedMcpPanel.tsx: Unified MCP management panel
- src/components/ui/checkbox.tsx: Checkbox component from shadcn/ui

**Features:**
- Unified panel with three-column layout: server info + app checkboxes + actions
- Multi-app control: Claude/Codex/Gemini checkboxes for each server
- Real-time stats: Show enabled server counts per app
- Full CRUD operations: Add, edit, delete, sync all servers

**Integration:**
- Replace old app-specific McpPanel with UnifiedMcpPanel in App.tsx
- Update McpFormModal to support unified mode with apps field
- Add i18n support: mcp.unifiedPanel namespace (zh/en)

**Type Safety:**
- Ensure McpServer.apps field always initialized
- Fix all test files to include apps field
- TypeScript type check passes 

**Architecture:**
- Single source of truth: mcp.servers manages all MCP configs
- Per-server app control: apps.claude/codex/gemini boolean flags
- Backward compatible: McpFormModal supports both unified and legacy modes

Next: P1 tasks (import dialogs, sub-components, tests)
2025-11-14 15:24:48 +08:00
Jason
32a6de074c docs: add comprehensive v3.7.0 unified MCP refactor plan
## Document Overview
Created detailed refactoring plan document covering:
- Project goals and architecture changes
- Data structure migration strategy (v3.6.x → v3.7.0)
- Complete development progress tracking
- Frontend remaining tasks roadmap
- Testing plan and delivery checklist

## Completed Work (Documented)
 Phase 1: Backend data structure migration
  - McpApps, McpServer, McpRoot definitions
  - Automatic migration logic
  - Integration into load() method

 Phase 2: Backend services refactor
  - Complete McpService rewrite
  - Single-server sync functions
  - New Tauri commands
  - Backward compatibility layer
  - All compilation errors fixed

 Phase 3 (Partial): Frontend types and API
  - Updated TypeScript types (McpApps, McpServer)
  - New API methods (getAllServers, toggleApp, etc.)

## Remaining Work (Documented)
📝 React Query hooks (useMcp.ts)
📝 UnifiedMcpPanel component
📝 i18n translations
📝 Main app integration
📝 Sub-components (table, form, import dialog)

## Key Highlights
- Detailed UI wireframes and component structure
- Step-by-step migration flow documentation
- Risk assessment and mitigation strategies
- Technical insights and architecture benefits

Related: v3.7.0 unified MCP management
2025-11-14 15:09:22 +08:00
Jason
ac09551563 feat(frontend): add unified MCP types and API layer for v3.7.0
## Type Definitions
- Update McpServer interface with new apps field (McpApps)
- Add McpApps interface for multi-app enable state
- Add McpServersMap type for server collections
- Mark enabled field as deprecated (use apps instead)
- Maintain backward compatibility with optional fields

## API Layer Updates
- Add unified MCP management methods to mcpApi:
  * getAllServers() - retrieve all servers with apps state
  * upsertUnifiedServer() - add/update server with apps
  * deleteUnifiedServer() - remove server
  * toggleApp() - enable/disable server for specific app
  * syncAllServers() - sync all enabled servers to live configs
- Import new McpServersMap type

## Code Organization
- Keep all types in src/types.ts (removed duplicate types/mcp.ts)
- Follow existing project structure conventions

Related: v3.7.0 unified MCP management
2025-11-14 13:01:47 +08:00
Jason
7ae2a9f556 fix(mcp): resolve compilation errors and add backward compatibility
## Compilation Fixes
- Add deprecated compatibility methods to McpService:
  * get_servers() - filters servers by app
  * set_enabled() - delegates to toggle_app()
  * sync_enabled() - syncs enabled servers for specific app
  * import_from_claude/codex/gemini() - wraps mcp:: functions
- Fix toml_edit type conversion in sync_single_server_to_codex():
  * Add json_server_to_toml_table() helper function
  * Manually construct toml_edit::Table instead of invalid serde conversion
- Fix get_codex_config_path() calls (returns PathBuf, not Result)
- Update upsert_mcp_server_in_config() to work with unified structure:
  * Converts old per-app API to unified McpServer structure
  * Preserves existing server data when updating
  * Supports sync_other_side parameter for multi-app enable
- Update delete_mcp_server_in_config() to ignore app parameter

## Backward Compatibility
- All old v3.6.x commands continue to work with deprecation warnings
- Frontend migration can be done incrementally
- Old commands transparently use new unified backend

## Status
 Backend compiles successfully (cargo check passes)
⚠️ 16 warnings (8 deprecation + 8 unused functions - expected)
2025-11-14 12:57:14 +08:00
Jason
c985db8f3d feat(mcp): implement unified MCP management for v3.7.0
BREAKING CHANGE: Migrate from app-specific MCP storage to unified structure

## Phase 1: Data Structure Migration
- Add McpApps struct with claude/codex/gemini boolean fields
- Add McpServer struct for unified server definition
- Add migration logic in MultiAppConfig::migrate_mcp_to_unified()
- Integrate automatic migration into MultiAppConfig::load()
- Support backward compatibility through Optional fields

## Phase 2: Backend Services Refactor
- Completely rewrite services/mcp.rs for unified management:
  * get_all_servers() - retrieve all MCP servers
  * upsert_server() - add/update unified server
  * delete_server() - remove server
  * toggle_app() - enable/disable server for specific app
  * sync_all_enabled() - sync to all live configs
- Add single-server sync functions to mcp.rs:
  * sync_single_server_to_claude/codex/gemini()
  * remove_server_from_claude/codex/gemini()
- Add read_mcp_servers_map() to claude_mcp.rs and gemini_mcp.rs
- Add new Tauri commands to commands/mcp.rs:
  * get_mcp_servers, upsert_mcp_server, delete_mcp_server
  * toggle_mcp_app, sync_all_mcp_servers
- Register new commands in lib.rs

## Migration Strategy
- Detects old structure (mcp.claude/codex/gemini.servers)
- Merges into unified mcp.servers with apps markers
- Handles conflicts by merging enabled apps
- Clears old structures after migration
- Saves migrated config automatically

## Known Issues
- Old commands still need compatibility layer (WIP)
- toml_edit type conversion needs fixing (WIP)
- Frontend not yet implemented (Phase 3 pending)

Related: v3.6.2 -> v3.7.0
2025-11-14 12:51:24 +08:00
Jason
c7b235bb98 fix(i18n): add missing Gemini MCP panel title and fix ternary logic
- Add mcp.geminiTitle to both zh.json and en.json
- Fix McpPanel title logic to handle all three apps (claude/codex/gemini)
- Previous logic would incorrectly display codexTitle for gemini
2025-11-14 10:35:55 +08:00
Jason
1616c63c0b feat(gemini): implement full MCP management functionality
- Add gemini_mcp.rs module for Gemini MCP file I/O operations
- Implement sync_enabled_to_gemini to export enabled MCPs to ~/.gemini/settings.json
- Implement import_from_gemini to import MCPs from Gemini config
- Add Gemini sync logic in services/mcp.rs (upsert_server, delete_server, set_enabled)
- Register Tauri commands for Gemini MCP sync and import
- Update frontend API calls and McpPanel to support Gemini

Fixes the issue where adding MCP servers in Gemini tab would not sync to ~/.gemini/settings.json
2025-11-14 10:02:27 +08:00
Jason
146b42fb68 feat(gemini): add config.json editor and common config functionality
Implements dual-editor pattern for Gemini providers, following the Codex architecture:
- Environment variables (.env format) editor
- Extended configuration (config.json) editor with common config support

New Components:
- GeminiConfigSections: Separate sections for env and config editing
- GeminiCommonConfigModal: Modal for editing common config snippets

New Hooks:
- useGeminiConfigState: Manages env/config separation and conversion
  - Converts between .env string format and JSON object
  - Validates JSON config structure
  - Extracts API Key and Base URL from env
- useGeminiCommonConfig: Handles common config snippets
  - Deep merge algorithm for combining configs
  - Remove common config logic for toggling off
  - localStorage persistence for snippets

Features:
- Format buttons for both env and config editors
- Common config toggle with deep merge/remove
- Error validation and display
- Auto-open modal on common config errors

Configuration Structure:
{
  "env": {
    "GOOGLE_GEMINI_BASE_URL": "https://...",
    "GEMINI_API_KEY": "sk-...",
    "GEMINI_MODEL": "gemini-2.5-pro"
  },
  "config": {
    "timeout": 30000,
    "maxRetries": 3
  }
}

This brings Gemini providers to feature parity with Claude and Codex.
2025-11-14 08:32:30 +08:00
Jason
0ea434a485 fix(i18n): deduplicate category labels by reusing providerForm keys
- Change ProviderForm to use providerForm.category* instead of providerPreset.category*
- Remove duplicate category keys from providerPreset namespace in both zh.json and en.json
- Fix naming inconsistency: use categoryAggregation (not categoryAggregator)
- Fixes issue where English UI would show Chinese defaultValue fallbacks

This ensures single source of truth for category labels and improves maintainability.
2025-11-14 08:31:09 +08:00
Jason
21fd7cc9fd feat: migrate Claude common config snippet from localStorage to config.json
Migrate the Claude common config snippet storage from browser localStorage
to the persistent config.json file for better cross-device sync and backup support.

**Backend Changes:**
- Add `claude_common_config_snippet` field to `MultiAppConfig` struct
- Add `get_claude_common_config_snippet` and `set_claude_common_config_snippet` Tauri commands
- Include JSON validation in the setter command

**Frontend Changes:**
- Create new `lib/api/config.ts` API module
- Refactor `useCommonConfigSnippet` hook to use config.json instead of localStorage
- Add automatic one-time migration from localStorage to config.json
- Add loading state during initialization

**Benefits:**
- Cross-device synchronization via backup/restore
- More reliable persistence than browser storage
- Centralized configuration management
- Seamless migration for existing users
2025-11-13 22:45:58 +08:00
Jason
434c64f38d fix(ui): prevent URL overflow in provider cards
Add max-width constraint to URL display to prevent long URLs from breaking card layout.
2025-11-13 17:14:49 +08:00
Jason
6d8e822f8d refactor(i18n): remove unnecessary translation for brand names
- Use hardcoded "Claude", "Codex", "Gemini" instead of i18n keys
- Brand names should not be translated across different locales
- Simplifies code by removing useTranslation hook from AppSwitcher
- Reduces maintenance overhead in translation files
2025-11-13 17:08:05 +08:00
Jason
2fae8c9275 fix(i18n): add internationalization support for app names
- Add i18n for Claude/Codex/Gemini app names in AppSwitcher
- Use useTranslation hook with existing translation keys
- Fix ASCII diagram alignment in README files
2025-11-13 16:37:58 +08:00
Jason
30c763ffe3 fix(prompt): improve i18n and error handling for auto-import
Address code review feedback for PR #214:

- Replace hardcoded Chinese strings with English in auto-imported prompts
  - Prompt name: "Auto-imported Prompt" instead of "初始提示词"
  - Description: "Automatically imported on first launch"
- Remove panic risk by replacing expect() with proper error propagation
- Use AppError::localized for bilingual error messages
- Extract get_base_dir_with_fallback() helper to eliminate code duplication
- Update test assertions to match new English strings
- Suppress false-positive dead_code warning on TempHome.dir field

All 5 tests passing with zero compiler warnings.
2025-11-13 15:43:37 +08:00
YoVinchen
e4d7999294 refactor(prompt): extract file path logic and implement auto-import on first launch (#214)
- Extract prompt file path logic to dedicated prompt_files module
- Refactor PromptService to use centralized path resolution
- Implement auto-import for existing prompt files on first startup
- Add comprehensive unit tests for auto-import functionality
2025-11-13 15:15:58 +08:00
YoVinchen
34f7139fda fix(usage-script): add input validation and boundary checks (#208)
- Backend: validate auto-query interval ≤ 1440 minutes (24 hours)
- Frontend: add number input sanitization and blur validation
- Add user-friendly error messages for invalid inputs
- Support auto-clamping to valid ranges with toast notifications
2025-11-13 11:28:48 +08:00
YoVinchen
a85f24f616 fix(gemini): relax validation when adding providers (#210)
Allow users to create Gemini provider configurations without API key
and fill it in later. Split validation into two modes:

- validate_gemini_settings: Basic structure check (used when adding)
- validate_gemini_settings_strict: Full validation (used when switching)

This fixes the error 'Gemini config missing required field: GEMINI_API_KEY'
when trying to add Gemini providers from presets like PackyCode or Google.

Changes:
- Add validate_gemini_settings_strict for switching validation
- Update write_gemini_live to use strict validation
- Add comprehensive tests for both validation modes
2025-11-13 11:28:28 +08:00
YoVinchen
b9743a463d feat(tray): add Gemini support to system tray menu (#209)
Refactor tray menu system to support three applications (Claude/Codex/Gemini):
- Introduce generic TrayAppSection structure and TRAY_SECTIONS array
- Implement append_provider_section and handle_provider_tray_event helper functions
- Enhance Gemini provider service with .env config read/write support
- Implement Gemini LiveSnapshot for atomic operations and rollback
- Update README documentation to reflect Gemini tray quick switching feature
2025-11-12 23:38:43 +08:00
YoVinchen
2f02514a14 feat(gemini): add Google Official branding with Gemini icon (#211)
Update Google Gemini preset to match Claude Official styling:
- Rename 'Google' to 'Google Official'
- Add GeminiIcon support in preset selector
- Add custom theme with Google blue (#4285F4) background
- Update PresetTheme type to support 'gemini' icon type

Changes:
- Add GeminiPresetTheme interface
- Add theme config to Google Official preset
- Import and render GeminiIcon in ProviderPresetSelector
- Update PresetTheme icon type to include 'gemini'
2025-11-12 22:41:26 +08:00
Jason
75866044bd update readme 2025-11-12 21:52:47 +08:00
YoVinchen
155532ea8c feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI

- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml

* feat(i18n): improve i18n for prompts and Markdown editor

- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs

* feat(prompts): add enable/disable toggle and simplify panel UI

- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
  reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content

* style: unify code formatting with trailing commas

* feat(prompts): add Gemini filename support to PromptFormModal

Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.

* fix(prompts): sync enabled prompt to file when updating

When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).

This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
2025-11-12 16:41:41 +08:00
YoVinchen
346f916048 refactor(endpoint): separate edit and create mode endpoint management (#192)
Optimize custom endpoint management logic to distinguish between edit and create modes:
- Edit mode: endpoints are read/written directly to backend via API
- Create mode: use draftCustomEndpoints to stage, save on submit
- Remove duplicate endpoint loading in useSpeedTestEndpoints
- Add isSaving state and initialCustomUrls tracking
2025-11-12 11:02:43 +08:00
YoVinchen
8a05e7bd3d feat(gemini): add Gemini provider integration (#202)
* feat(gemini): add Gemini provider integration

- Add gemini_config.rs module for .env file parsing
- Extend AppType enum to support Gemini
- Implement GeminiConfigEditor and GeminiFormFields components
- Add GeminiIcon with standardized 1024x1024 viewBox
- Add Gemini provider presets configuration
- Update i18n translations for Gemini support
- Extend ProviderService and McpService for Gemini

* fix(gemini): resolve TypeScript errors, add i18n support, and fix MCP logic

**Critical Fixes:**
- Fix TS2741 errors in tests/msw/state.ts by adding missing Gemini type definitions
- Fix ProviderCard.extractApiUrl to support GOOGLE_GEMINI_BASE_URL display
- Add missing apps.gemini i18n keys (zh/en) for proper app name display
- Fix MCP service Gemini cross-app duplication logic to prevent self-copy

**Technical Details:**
- tests/msw/state.ts: Add gemini default providers, current ID, and MCP config
- ProviderCard.tsx: Check both ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL
- services/mcp.rs: Skip Gemini in sync_other_side logic with unreachable!() guards
- Run pnpm format to auto-fix code style issues

**Verification:**
-  pnpm typecheck passes
-  pnpm format completed

* feat(gemini): enhance authentication and config parsing

- Add strict and lenient .env parsing modes
- Implement PackyCode partner authentication detection
- Support Google OAuth official authentication
- Auto-configure security.auth.selectedType for PackyCode
- Add comprehensive test coverage for all auth types
- Update i18n for OAuth hints and Gemini config

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-11-12 10:47:34 +08:00
240 changed files with 24819 additions and 4746 deletions

5
.gitignore vendored
View File

@@ -9,6 +9,11 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
GEMINI.md
/.claude
/.codex
/.gemini
/.cc-switch
/.idea
/.vscode
vitest-report.json

View File

@@ -5,6 +5,247 @@ 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
#### Gemini CLI Integration
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **MCP management** - Full MCP configuration capabilities for Gemini
- **Provider presets**
- Google Official (OAuth authentication)
- PackyCode (partner integration)
- Custom endpoint support
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
- **System tray integration** - Quick-switch Gemini providers from tray menu
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
#### MCP v3.7.0 Unified Architecture
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
- **Extended field support** - Preserve custom fields in Codex TOML conversion
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
- **UX improvements**
- Default app selection in forms
- JSON formatter for config validation
- Improved layout and visual hierarchy
- Better validation error messages
#### Claude Skills Management System
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
- **Pre-configured repositories**
- `ComposioHQ/awesome-claude-skills` (curated collection)
- `anthropics/skills` (official Anthropic skills)
- `cexll/myclaude` (community, with subdirectory scanning)
- **Lifecycle management**
- One-click install to `~/.claude/skills/`
- Safe uninstall with state tracking
- Update checking (infrastructure ready)
- **Custom repository support** - Add any GitHub repo as a skill source
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
- **Frontend interface**
- SkillsPage: Browse and manage skills
- SkillCard: Visual skill presentation
- RepoManager: Repository management dialog
- **State persistence** - Installation state stored in `skills.json`
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
#### Prompts (System Prompts) Management
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
- **Cross-app support**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
- **Smart synchronization**
- Auto-write to live files on enable
- Content backfill protection (save current before switching)
- First-launch auto-import from live files
- **Single-active enforcement** - Only one prompt can be active at a time
- **Delete protection** - Cannot delete active prompts
- **Backend service** - `PromptService` (213 lines) with CRUD operations
- **Frontend components**
- PromptPanel: Main management interface (177 lines)
- PromptFormModal: Edit dialog with validation (160 lines)
- MarkdownEditor: CodeMirror integration (159 lines)
- usePromptActions: Business logic hook (152 lines)
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
#### Deep Link Protocol (ccswitch://)
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
- **Provider import** - Import provider configurations from URLs or shared links
- **Lifecycle integration** - Deep link handling integrated into app startup
- **Cross-platform support** - Works on Windows, macOS, and Linux
#### Environment Variable Conflict Detection
- **Claude & Codex detection** - Identify conflicting environment variables
- **Gemini auto-detection** - Automatic environment variable discovery
- **Conflict management** - UI for resolving configuration conflicts
- **Prevention system** - Warn before overwriting existing configurations
### New Features
#### Provider Management
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
- **Kimi For Coding** - Moonshot AI coding assistant
- **BaiLing preset** - BaiLing AI integration
- **Removed AnyRouter preset** - Discontinued provider
- **Model configuration** - Support for custom model names in Codex and Gemini
- **Provider notes field** - Add custom notes to providers for better organization
#### Configuration Management
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
- **Unified persistence** - Common config snippets now shared across all apps
- **Auto-import on first launch** - Automatically import configs from live files on first run
- **Backfill priority fix** - Correct priority handling when enabling prompts
#### UI/UX Improvements
- **macOS native design** - Migrated color scheme to macOS native design system
- **Window centering** - Default window position centered on screen
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
- **URL overflow prevention** - Fixed overflow in provider cards
- **Error notification enhancement** - Copy-to-clipboard for error messages
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
### Improvements
#### Architecture
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
- **Unified structure** - Default initialization with v3.7.0 unified structure
- **Backward compatibility** - Compilation fixes for older configs
- **Code formatting** - Applied consistent formatting across backend and frontend
#### Platform Compatibility
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
#### Configuration
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
- **Gemini form sync** - Fixed form fields syncing with environment editor
- **Gemini config reading** - Read from both `.env` and `settings.json`
- **Validation improvements** - Enhanced input validation and boundary checks
#### Internationalization
- **JSON syntax fixes** - Resolved syntax errors in locale files
- **App name i18n** - Added internationalization support for app names
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
- **Gemini MCP title** - Added missing Gemini MCP panel title
### Bug Fixes
#### Critical Fixes
- **Usage script validation** - Added input validation and boundary checks
- **Gemini validation** - Relaxed validation when adding providers
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
- **Password input** - Fixed white screen crash (FormLabel → Label)
#### Stability
- **Tray menu safety** - Replaced unwrap with safe pattern matching
- **Error isolation** - Tray menu update failures don't block main operations
- **Import classification** - Set category to custom for imported default configs
#### UI Fixes
- **Model placeholders** - Removed misleading model input placeholders
- **Base URL population** - Auto-fill base URL for non-official providers
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
### Technical Improvements
#### Code Quality
- **Type safety** - Complete TypeScript type coverage across codebase
- **Test improvements** - Simplified boolean assertions in tests
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
- **Code refactoring** - Extracted templates, optimized logic flows
#### Dependencies
- **Tauri** - Updated to 2.8.x series
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
- **winreg** - Updated to v0.52 (Windows compatibility)
#### Performance
- **Startup optimization** - Removed legacy migration scanning
- **Lock management** - Improved RwLock usage to prevent deadlocks
- **Background query** - Enabled background mode for usage polling
### Statistics
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
- **New modules**:
- Skills: 2,034 lines (21 files)
- Prompts: 1,302 lines (20 files)
- Gemini: ~1,000 lines (multiple files)
- MCP refactor: ~3,000 lines (refactored)
### Strategic Positioning
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
1. **Capability Extension** - Skills provide external ability integration
2. **Behavior Customization** - Prompts enable AI personality presets
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
4. **Ecosystem Openness** - Deep links enable community sharing
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
6. **Intelligent Detection** - Auto-discovery of environment conflicts
### Notes
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
- Skills and Prompts management are new features requiring no migration
- Gemini CLI support requires Gemini CLI to be installed separately
- MCP v3.7.0 unified structure is backward compatible with previous configs
## [3.6.0] - 2025-11-07
### ✨ New Features
@@ -73,6 +314,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 🏗️ Technical Improvements (For Developers)
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
@@ -80,17 +322,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
- **Stage 3**: Component splitting and business logic extraction
- **Stage 4**: Code cleanup and formatting unification
**Testing System**:
- Hooks unit tests 100% coverage
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
- MSW mocking backend API to ensure test independence
**Code Quality**:
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
- `AppType` renamed to `AppId`: Semantically clearer
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
@@ -98,6 +343,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
**Internal Optimizations**:
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
-**Impact**: Improved startup performance, cleaner code
-**Compatibility**: v2 format configs fully compatible, no action required
@@ -361,6 +607,7 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes

132
README.md
View File

@@ -1,15 +1,20 @@
<div align="center">
# Claude Code & Codex Provider Switcher
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.6.1-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/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<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_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>
@@ -30,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
@@ -40,12 +51,49 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.6.1 | [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 API configurations
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **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)
@@ -58,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**
@@ -100,6 +147,14 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
### ArchLinux 用户
**Install via paru (Recommended)**
```bash
paru -S cc-switch-bin
```
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
@@ -112,15 +167,42 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
2. **Switch Provider**:
- Main UI: Select provider → Click "Enable"
- System Tray: Click provider name directly (instant effect)
3. **Takes Effect**: Restart terminal or Claude Code/Codex to apply changes
4. **Back to Official**: Select "Official Login" preset, restart terminal, then use `/login` (Claude) or official login flow (Codex)
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
### 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
@@ -134,11 +216,19 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
- API key field: `OPENAI_API_KEY` in `auth.json`
- MCP servers: `~/.codex/config.toml``[mcp.servers]`
- MCP servers: `~/.codex/config.toml``[mcp_servers]` tables
**Gemini**
- 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)
@@ -158,18 +248,18 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ │ Components │ │ Hooks │ │ TanStack Query │
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │
│ └─────────────┘ └──────────────┘ └──────────────────┘
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ Backend (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ │ Commands │ │ Services │ │ Models/Config │
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │
│ └─────────────┘ └──────────────┘ └──────────────────┘
└─────────────────────────────────────────────────────────────┘
```

View File

@@ -1,15 +1,20 @@
<div align="center">
# Claude Code & Codex 供应商管理器
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.6.1-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/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
<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>
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
</div>
@@ -30,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>
## 界面预览
@@ -40,12 +51,49 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
## 功能特性
### 当前版本v3.6.1 | [完整更新日志](CHANGELOG.md)
### 当前版本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 CodeCodex 的 API 配置
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
- **供应商管理**:一键切换 Claude CodeCodex 与 Gemini 的 API 配置
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**完整的中英文本地化UI、错误、托盘
@@ -58,7 +106,6 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
- 细粒度模型配置四层Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
- 新增预设DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
**系统功能**
@@ -100,6 +147,14 @@ brew upgrade --cask cc-switch
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### ArchLinux 用户
**通过 paru 安装(推荐)**
```bash
paru -S cc-switch-bin
```
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
@@ -112,15 +167,42 @@ brew upgrade --cask cc-switch
2. **切换供应商**
- 主界面:选择供应商 → 点击"启用"
- 系统托盘:直接点击供应商名称(立即生效)
3. **生效方式**:重启终端或 Claude Code/Codex 以应用更改
4. **恢复官方登录**:选择"官方登录"预设,重启终端后使用 `/login`Claude或官方登录流程Codex
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
4. **恢复官方登录**:选择"官方登录"预设Claude/Codex或"Google 官方"预设Gemini重启对应客户端后按照其登录/OAuth 流程操作
### 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`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
### 配置文件
@@ -134,11 +216,19 @@ brew upgrade --cask cc-switch
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
- MCP 服务器:`~/.codex/config.toml``[mcp.servers]`
- MCP 服务器:`~/.codex/config.toml``[mcp_servers]`
**Gemini**
- 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 个)
@@ -157,19 +247,19 @@ brew upgrade --cask cc-switch
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
│ 前端 (React + TS)
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ │ Components │ │ Hooks │ │ TanStack Query │
│ │ UI │──│ 业务逻辑 │──│ 缓存/同步
│ └─────────────┘ └──────────────┘ └──────────────────┘
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ 后端 (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
│ 后端 (Tauri + Rust)
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ │ Commands │ │ Services │ │ Models/Config │
│ │ API 层 │──│ 业务层 │──│ 数据
│ └─────────────┘ └──────────────┘ └──────────────────┘
└─────────────────────────────────────────────────────────────┘
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 227 KiB

1674
deplink.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,439 @@
# CC Switch v3.7.0
> From Provider Switcher to All-in-One AI CLI Management Platform
**[中文更新说明 Chinese Documentation →](release-note-v3.7.0-zh.md)**
---
## Overview
CC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.
**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 `skills.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+
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`
### 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)
---
## 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,435 @@
# CC Switch v3.7.0
> 从供应商切换器到 AI CLI 一体化管理平台
**[English Version →](release-note-v3.7.0-en.md)**
---
## 概览
CC Switch v3.7.0 新增六大核心功能,新增超过 18,000 行代码。
**发布日期**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
- **状态**:持久化存储在 `skills.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+
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-v3.7.0-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-v3.7.0-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-v3.7.0-Linux.AppImage``.deb`
### 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 & Geimini 集成实现
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
- 社区成员的测试和反馈
### 赞助商
**Z.ai** - GLM CODING PLAN 赞助商
[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API 中继服务合作伙伴
[使用 "cc-switch" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)
---
## 反馈与支持
- **问题反馈**[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 预览**(暂定):
- 本地代理功能
敬请期待更多更新!

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

@@ -0,0 +1,863 @@
# v3.7.0 统一 MCP 管理重构计划
## 📋 项目概述
**目标**:将原有的按应用分离的 MCP 管理Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
**版本**v3.6.2 → v3.7.0
**开始时间**2025-11-14
---
## 🎯 核心需求
### 原有架构v3.6.x
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
└─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓
mcp.claude mcp.codex mcp.gemini
{servers} {servers} {servers}
```
### 新架构v3.7.0
```
┌───────────────────────────────────────┐
│ 统一 MCP 管理面板 │
│ ┌────────┬────────┬────────┬────┐ │
│ │ 服务器 │ Claude │ Codex │Gem │ │
│ ├────────┼────────┼────────┼────┤ │
│ │ mcp-1 │ ✓ │ ✓ │ │ │
│ │ mcp-2 │ ✓ │ │ ✓ │ │
│ └────────┴────────┴────────┴────┘ │
└───────────────────────────────────────┘
mcp.servers
{
"mcp-1": {
apps: {claude: true, codex: true, gemini: false}
}
}
```
---
## 📐 技术架构
### 数据结构设计
#### 新增McpApps应用启用状态
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct McpApps {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
```
#### 更新McpServer统一服务器定义
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
pub server: serde_json::Value, // 连接配置stdio/http
pub apps: McpApps, // 新增:标记应用到哪些客户端
pub description: Option<String>,
pub homepage: Option<String>,
pub docs: Option<String>,
pub tags: Vec<String>,
}
```
#### 更新McpRoot新旧结构并存
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpRoot {
// v3.7.0 新结构
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<HashMap<String, McpServer>>,
// v3.6.x 旧结构(保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
}
```
### 迁移策略
```
旧配置 (v3.6.x) 新配置 (v3.7.0)
───────────────── ─────────────────
mcp: mcp:
claude: servers:
servers: mcp-fetch:
mcp-fetch: {...} → id: "mcp-fetch"
codex: server: {...}
servers: apps:
mcp-filesystem: {...} claude: true
codex: true
gemini: false
```
**迁移逻辑**
1. 检测 `mcp.servers` 是否存在
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
3. 合并同 id 服务器的 apps 字段
4. 清空旧结构字段
5. 保存配置(自动触发)
---
## ✅ 开发进度
### Phase 1: 后端数据结构与迁移 ✅ 已完成
#### 1.1 修改数据结构app_config.rs
**文件**`src-tauri/src/app_config.rs`
**变更**
- ✅ 新增 `McpApps` 结构体lines 30-62
- ✅ 新增 `McpServer` 结构体lines 64-79
- ✅ 更新 `McpRoot` 支持新旧结构lines 81-96
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 1.2 实现迁移逻辑 ✅
**文件**`src-tauri/src/app_config.rs`
**实现**
-`migrate_mcp_to_unified()` 方法lines 380-509
- 从旧结构收集所有服务器
- 按 id 合并重复服务器
- 处理冲突(合并 apps 字段)
- 清空旧结构
- ✅ 集成到 `MultiAppConfig::load()` 方法lines 252-257
- 自动检测并执行迁移
- 迁移后保存配置
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
---
### Phase 2: 后端服务层重构 ✅ 已完成
#### 2.1 重写 McpService ✅
**文件**`src-tauri/src/services/mcp.rs`
**新增方法**
-`get_all_servers()` - 获取所有服务器lines 13-27
-`upsert_server()` - 添加/更新服务器lines 30-52
-`delete_server()` - 删除服务器lines 55-75
-`toggle_app()` - 切换应用启用状态lines 78-111
-`sync_all_enabled()` - 同步所有启用的服务器lines 180-188
**兼容层方法**(已废弃):
-`get_servers()` - 按应用过滤服务器lines 196-210
-`set_enabled()` - 委托到 toggle_applines 213-222
-`sync_enabled()` - 同步特定应用lines 225-236
-`import_from_claude/codex/gemini()` - 导入包装lines 239-266
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 2.2 新增同步函数mcp.rs
**文件**`src-tauri/src/mcp.rs`
**新增函数**lines 800-965
-`json_server_to_toml_table()` - JSON → TOML 转换助手lines 828-889
-`sync_single_server_to_claude()` - 同步单个服务器到 Claudelines 800-814
-`remove_server_from_claude()` - 从 Claude 移除服务器lines 817-826
-`sync_single_server_to_codex()` - 同步单个服务器到 Codexlines 891-936
-`remove_server_from_codex()` - 从 Codex 移除服务器lines 939-965
-`sync_single_server_to_gemini()` - 同步单个服务器到 Geminilines 967-977
-`remove_server_from_gemini()` - 从 Gemini 移除服务器lines 980-989
**关键修复**
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
**修复提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.3 新增 Tauri Commands ✅
**文件**`src-tauri/src/commands/mcp.rs`
**新增命令**lines 147-196
-`get_mcp_servers()` - 获取所有服务器lines 154-159
-`upsert_mcp_server()` - 添加/更新服务器lines 162-168
-`delete_mcp_server()` - 删除服务器lines 171-177
-`toggle_mcp_app()` - 切换应用状态lines 180-189
-`sync_all_mcp_servers()` - 同步所有服务器lines 192-195
**更新旧命令**(兼容层):
-`upsert_mcp_server_in_config()` - 转换为统一结构lines 68-131
-`delete_mcp_server_in_config()` - 忽略 app 参数lines 134-141
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
**修复提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.4 注册新命令lib.rs
**文件**`src-tauri/src/lib.rs`
**变更**
- ✅ 导出 `McpServer` 类型line 21
- ✅ 导出新增的 mcp 同步函数lines 26-31
- ✅ 注册 5 个新命令到 invoke_handlerlines 550-555
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 2.5 添加缺失的函数claude_mcp.rs & gemini_mcp.rs
**文件**
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
**新增**
-`read_mcp_servers_map()` - 读取现有 MCP 服务器映射
**提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.6 编译验证 ✅
**状态**:✅ 编译成功
- ⚠️ 16 个警告8 个废弃警告 + 8 个未使用函数警告 - 预期内)
- ✅ 0 个错误
**提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
---
### Phase 3: 前端开发 ⚠️ 部分完成
#### 3.1 TypeScript 类型定义 ✅
**文件**`src/types.ts`
**变更**
- ✅ 新增 `McpApps` 接口lines 129-133
- ✅ 更新 `McpServer` 接口lines 136-149
- 新增 `apps: McpApps` 字段
- `name` 改为必填
- 标记 `enabled` 为废弃
- ✅ 新增 `McpServersMap` 类型别名line 152
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
**提交**`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
#### 3.2 API 层更新 ✅
**文件**`src/lib/api/mcp.ts`
**新增方法**lines 99-141
-`getAllServers()` - 获取所有服务器lines 106-108
-`upsertUnifiedServer()` - 添加/更新服务器lines 113-115
-`deleteUnifiedServer()` - 删除服务器lines 120-122
-`toggleApp()` - 切换应用状态lines 127-133
-`syncAllServers()` - 同步所有服务器lines 138-140
**导入更新**
- ✅ 导入 `McpServersMap` 类型line 6
**提交**`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
#### 3.3 React Query Hooks 📝 待开发
**计划文件**`src/hooks/useMcp.ts`
**需要实现的 Hooks**
```typescript
// 查询 hooks
export function useAllMcpServers() {
return useQuery({
queryKey: ['mcp', 'all'],
queryFn: () => mcpApi.getAllServers(),
});
}
// 变更 hooks
export function useUpsertMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useToggleMcpApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ serverId, app, enabled }: {
serverId: string;
app: AppId;
enabled: boolean;
}) => mcpApi.toggleApp(serverId, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useSyncAllMcpServers() {
return useMutation({
mutationFn: () => mcpApi.syncAllServers(),
});
}
```
**依赖**
- `@tanstack/react-query` (已安装)
- `src/lib/api/mcp.ts` (✅ 已完成)
- `src/types.ts` (✅ 已完成)
#### 3.4 统一 MCP 面板组件 📝 待开发
**计划文件**`src/components/mcp/UnifiedMcpPanel.tsx`
**组件结构**
```typescript
interface UnifiedMcpPanelProps {
className?: string;
}
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
const { t } = useTranslation();
const { data: servers, isLoading } = useAllMcpServers();
const toggleApp = useToggleMcpApp();
const deleteServer = useDeleteMcpServer();
const syncAll = useSyncAllMcpServers();
// 组件实现...
}
```
**UI 设计**
```
┌─────────────────────────────────────────────────────┐
│ MCP 服务器管理 ┌──────────┐ │
│ │ 添加服务器 │ │
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
│ └─────┘ └──────────────┘ └─────────┘ │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
│ ├─────────────────────────────────────────────┤ │
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
**功能特性**
- 📋 服务器列表展示(名称、描述、标签)
- ☑️ 三个复选框控制应用启用状态Claude/Codex/Gemini
- 添加新服务器(表单模态框)
- ✏️ 编辑服务器(表单模态框)
- 🗑️ 删除服务器(确认对话框)
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
- 🔄 同步全部(手动触发同步到 live 配置)
- 🔍 搜索过滤
- 🏷️ 标签过滤
**子组件**
1. **McpServerTable** (`McpServerTable.tsx`)
- 服务器列表表格
- 应用复选框
- 操作按钮(编辑、删除)
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
- 添加/编辑表单
- stdio/http 类型切换
- 应用选择(多选)
- 元信息编辑(描述、标签、链接)
3. **McpImportDialog** (`McpImportDialog.tsx`)
- 选择导入来源Claude/Codex/Gemini
- 服务器预览
- 批量导入
**依赖组件**(来自 shadcn/ui
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
- `Checkbox`
- `Button`
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
- `Input`, `Textarea`, `Label`
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
- `Badge`
- `Tooltip`
#### 3.5 主界面集成 📝 待开发
**文件**`src/App.tsx`
**变更计划**
```typescript
// 原有代码v3.6.x
{currentApp === 'claude' && <ClaudeMcpPanel />}
{currentApp === 'codex' && <CodexMcpPanel />}
{currentApp === 'gemini' && <GeminiMcpPanel />}
// 新代码v3.7.0
<UnifiedMcpPanel />
```
**移除的组件**
- `ClaudeMcpPanel.tsx`
- `CodexMcpPanel.tsx`
- `GeminiMcpPanel.tsx`
**注意**:保留旧组件文件备份,以便回滚
#### 3.6 国际化文本更新 📝 待开发
**文件**
- `src/locales/zh/translation.json`
- `src/locales/en/translation.json`
**需要添加的翻译键**
```json
{
"mcp": {
"unifiedPanel": {
"title": "MCP 服务器管理 / MCP Server Management",
"addServer": "添加服务器 / Add Server",
"editServer": "编辑服务器 / Edit Server",
"deleteServer": "删除服务器 / Delete Server",
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
"syncAll": "同步全部 / Sync All",
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
"importFrom": "导入自... / Import from...",
"search": "搜索服务器... / Search servers...",
"noServers": "暂无服务器 / No servers yet",
"enabledApps": "启用的应用 / Enabled Apps",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
},
"form": {
"id": "服务器 ID / Server ID",
"name": "显示名称 / Display Name",
"type": "类型 / Type",
"stdio": "本地进程 / Local Process",
"http": "远程服务 / Remote Service",
"command": "命令 / Command",
"args": "参数 / Arguments",
"env": "环境变量 / Environment Variables",
"cwd": "工作目录 / Working Directory",
"url": "URL",
"headers": "请求头 / Headers",
"description": "描述 / Description",
"tags": "标签 / Tags",
"homepage": "主页 / Homepage",
"docs": "文档 / Documentation",
"selectApps": "选择应用 / Select Apps",
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
},
"table": {
"name": "名称 / Name",
"type": "类型 / Type",
"apps": "应用 / Apps",
"actions": "操作 / Actions",
"edit": "编辑 / Edit",
"delete": "删除 / Delete"
},
"import": {
"title": "导入 MCP 服务器 / Import MCP Servers",
"fromClaude": "从 Claude 导入 / Import from Claude",
"fromCodex": "从 Codex 导入 / Import from Codex",
"fromGemini": "从 Gemini 导入 / Import from Gemini",
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
"noServersFound": "未找到可导入的服务器 / No servers found to import"
}
}
}
}
```
---
## 🔄 迁移流程
### 用户体验
```
1. 用户升级到 v3.7.0
2. 首次启动应用
3. 后端自动执行迁移
- 检测旧结构 (mcp.claude/codex/gemini.servers)
- 合并到统一结构 (mcp.servers)
- 保存迁移后的配置
- 日志记录迁移详情
4. 前端加载新面板
- 显示所有服务器
- 三个复选框显示各应用启用状态
5. 用户无缝使用
```
### 数据完整性保证
1. **迁移前验证**
- ✅ 校验旧结构合法性
- ✅ 记录迁移前状态
2. **迁移中处理**
- ✅ 合并同 id 服务器的 apps 字段
- ✅ 处理 id 冲突(保留第一个,记录警告)
- ✅ 保留所有元信息(描述、标签、链接)
3. **迁移后清理**
- ✅ 清空旧结构claude/codex/gemini
- ✅ 自动保存新配置
- ✅ 日志记录迁移完成
4. **回滚机制**
- 配置文件有备份(`config.v1.backup.<timestamp>.json`
- 迁移失败时可手动回滚
---
## 🧪 测试计划
### 后端测试 ✅ 已验证
- [x] 编译测试cargo check
- [x] 数据结构序列化/反序列化
- [ ] 迁移逻辑单元测试
- [ ] 服务层方法测试
- [ ] 同步函数测试
### 前端测试 ⏳ 待进行
- [ ] TypeScript 类型检查
- [ ] API 调用测试
- [ ] 组件渲染测试
- [ ] 用户交互测试
- [ ] 国际化文本检查
### 集成测试 ⏳ 待进行
- [ ] 完整迁移流程测试
- [ ] 从空配置启动
- [ ] 从 v3.6.x 配置升级
- [ ] 多服务器合并场景
- [ ] 冲突处理验证
- [ ] 多应用同步测试
- [ ] 启用单个应用
- [ ] 启用多个应用
- [ ] 动态切换应用
- [ ] 同步到 live 配置验证
- [ ] 边界情况测试
- [ ] 空服务器列表
- [ ] 超长服务器名称
- [ ] 特殊字符处理
- [ ] 并发操作
---
## 📦 交付清单
### 代码文件
#### 后端Rust✅ 已完成
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
- [x] `src-tauri/src/lib.rs` - 命令注册
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
#### 前端TypeScript/React 部分完成
- [x] `src/types.ts` - 类型定义更新
- [x] `src/lib/api/mcp.ts` - API 层更新
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
- [ ] `src/App.tsx` - 主界面集成
- [ ] `src/locales/zh/translation.json` - 中文翻译
- [ ] `src/locales/en/translation.json` - 英文翻译
### 文档
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
### Git 提交记录 ✅
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
---
## 🎯 下一步行动
### 立即任务(优先级 P0
1.**实现 useMcp Hook**
- 文件:`src/hooks/useMcp.ts`
- 估时1-2 小时
- 依赖API 层(已完成)
2.**创建 UnifiedMcpPanel 核心组件**
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
- 估时3-4 小时
- 依赖useMcp Hook
3.**添加国际化文本**
- 文件:`src/locales/{zh,en}/translation.json`
- 估时30 分钟
4.**集成到主界面**
- 文件:`src/App.tsx`
- 估时30 分钟
- 依赖UnifiedMcpPanel 组件
### 次要任务(优先级 P1
5.**实现子组件**
- McpServerTable
- McpServerFormModal
- McpImportDialog
- 估时4-6 小时
6.**编写测试用例**
- 后端单元测试
- 前端组件测试
- 集成测试
- 估时6-8 小时
7.**编写用户文档**
- 升级指南
- API 变更说明
- 估时2-3 小时
### 优化任务(优先级 P2
8.**性能优化**
- 服务器列表虚拟滚动
- 批量操作优化
- 估时2-3 小时
9.**用户体验增强**
- 添加加载状态
- 添加错误提示
- 添加操作确认
- 估时2-3 小时
10.**代码清理**
- 移除旧的分应用面板组件
- 清理废弃代码
- 代码格式化
- 估时1-2 小时
---
## 💡 技术亮点
### 1. 平滑迁移机制
- ✅ 自动检测旧配置并迁移
- ✅ 新旧结构并存(过渡期)
- ✅ 无需用户手动操作
- ✅ 保留所有历史数据
### 2. 向后兼容
- ✅ 旧命令继续可用(带废弃警告)
- ✅ 前端可增量更新
- ✅ 渐进式重构策略
### 3. 类型安全
- ✅ Rust 强类型保证数据完整性
- ✅ TypeScript 类型定义与后端一致
- ✅ serde 序列化/反序列化自动处理
### 4. 清晰的架构分层
```
Frontend (React)
↓ (Tauri IPC)
Commands Layer
Services Layer
Data Layer (Config + Live Sync)
```
### 5. SSOT 原则
- 单一配置源:`~/.cc-switch/config.json`
- 统一管理:`mcp.servers` 字段
- 按需同步:写入各应用 live 配置
---
## 📚 参考资源
### 内部文档
- [项目 README](../README.md)
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
- [架构文档](../CLAUDE.md#架构概述)
### 相关 Issues/PRs
- 无(新功能开发)
### 技术栈文档
- [Tauri 2.0](https://tauri.app/v1/guides/)
- [React 18](https://react.dev/)
- [TanStack Query](https://tanstack.com/query/latest)
- [shadcn/ui](https://ui.shadcn.com/)
- [serde](https://serde.rs/)
---
## 📝 变更日志
### 2025-11-14
- ✅ 完成后端 Phase 1 & 2数据结构、服务层、命令层
- ✅ 修复所有编译错误
- ✅ 完成前端类型定义和 API 层
- ✅ 创建本重构计划文档
### 待更新...
---
## 👥 团队协作
**开发者**Claude Code (AI Assistant) + User
**审查者**User
**测试者**User
---
## ⚠️ 风险与对策
### 风险 1迁移数据丢失
**概率**:低
**影响**:高
**对策**
- ✅ 迁移前自动备份配置
- ✅ 详细日志记录
- ✅ 测试各种边界情况
### 风险 2性能问题大量服务器
**概率**:中
**影响**:中
**对策**
- ⬜ 实现虚拟滚动
- ⬜ 分页或懒加载
- ⬜ 性能测试
### 风险 3兼容性问题
**概率**:中
**影响**:中
**对策**
- ✅ 保留旧命令兼容层
- ✅ 前端增量更新
- ⬜ 多版本测试
### 风险 4用户学习成本
**概率**:低
**影响**:低
**对策**
- ⬜ 清晰的 UI 设计
- ⬜ 详细的升级指南
- ⬜ 操作提示和引导
---
## 🎉 预期收益
### 用户体验提升
-**简化操作**:不再需要在不同应用面板切换
-**统一视图**:一目了然看到所有 MCP 配置
-**灵活配置**:轻松控制每个 MCP 应用到哪些客户端
### 代码质量提升
-**架构优化**:统一数据源,消除冗余
-**维护性**:单一面板组件,代码更简洁
-**扩展性**:未来添加新应用(如 Cursor更容易
### 性能提升
-**减少重复加载**:统一管理减少配置文件读写
-**更快同步**:批量操作更高效
---
## 📞 联系方式
**问题反馈**[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
**功能建议**[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
---
**文档版本**v1.0
**最后更新**2025-11-14
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)

View File

@@ -1,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.6.2",
"description": "Claude Code & Codex 供应商切换工具",
"version": "3.7.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -37,6 +37,7 @@
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -45,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",
@@ -53,6 +55,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
@@ -75,5 +78,6 @@
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
}
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

139
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lint':
specifier: ^6.8.5
version: 6.8.5
@@ -38,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)
@@ -62,6 +68,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tailwindcss/vite':
specifier: ^4.1.13
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
@@ -280,12 +289,21 @@ packages:
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
@@ -573,9 +591,15 @@ packages:
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/html@1.3.12':
resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
@@ -585,6 +609,12 @@ packages:
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@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==}
@@ -821,6 +851,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -856,6 +899,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
@@ -967,6 +1019,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.4':
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -2468,6 +2533,26 @@ snapshots:
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@lezer/html': 1.3.12
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.18.7
@@ -2483,6 +2568,16 @@ snapshots:
'@codemirror/language': 6.11.3
'@lezer/json': 1.0.3
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/markdown': 1.6.0
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
@@ -2713,10 +2808,22 @@ snapshots:
'@lezer/common@1.2.3': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/html@1.3.12':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.2.3
@@ -2733,6 +2840,13 @@ snapshots:
dependencies:
'@lezer/common': 1.2.3
'@lezer/markdown@1.6.0':
dependencies:
'@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':
@@ -2968,6 +3082,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3021,6 +3144,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
'@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.23
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3115,6 +3245,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/rect@1.1.1': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}

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}`);

468
src-tauri/Cargo.lock generated
View File

@@ -17,6 +17,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -280,6 +291,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"
@@ -484,6 +506,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -558,26 +599,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cc-switch"
version = "3.6.2"
version = "3.7.1"
dependencies = [
"anyhow",
"auto-launch",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"once_cell",
"regex",
"reqwest",
"rquickjs",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
@@ -585,10 +635,14 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"url",
"winreg 0.52.0",
"zip 2.4.2",
]
[[package]]
@@ -644,6 +698,16 @@ dependencies = [
"windows-link 0.2.0",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -663,6 +727,32 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -728,6 +818,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -752,6 +857,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -834,6 +945,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
[[package]]
name = "deranged"
version = "0.5.4"
@@ -876,6 +993,16 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"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]]
@@ -896,6 +1023,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"
@@ -981,6 +1119,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1034,7 +1181,7 @@ dependencies = [
"rustc_version",
"toml 0.9.7",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -1669,6 +1816,12 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -1699,6 +1852,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1745,6 +1907,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1992,6 +2160,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "io-uring"
version = "0.7.10"
@@ -2089,6 +2266,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.81"
@@ -2247,6 +2434,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -2776,6 +2984,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -2860,6 +3078,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3621,6 +3849,16 @@ dependencies = [
"cc",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.38.0"
@@ -3727,6 +3965,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3790,6 +4037,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "seahash"
version = "4.1.0"
@@ -3962,6 +4215,44 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.11.4",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -3994,6 +4285,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4320,6 +4622,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -4435,6 +4738,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.0"
@@ -4589,7 +4913,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -4789,6 +5113,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -5162,6 +5495,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5712,6 +6051,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -6081,6 +6431,25 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -6188,6 +6557,15 @@ dependencies = [
"rustix",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.0"
@@ -6319,6 +6697,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "zerotrie"
@@ -6353,6 +6745,36 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap 2.11.4",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.17",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zip"
version = "4.6.1"
@@ -6365,6 +6787,46 @@ dependencies = [
"memchr",
]
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.7.0"

View File

@@ -1,7 +1,7 @@
[package]
name = "cc-switch"
version = "3.6.2"
description = "Claude Code & Codex 供应商配置管理工具"
version = "3.7.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/cc-switch"
@@ -25,14 +25,15 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = "0.4"
tauri = { version = "2.8.2", features = ["tray-icon"] }
chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
@@ -42,10 +43,21 @@ futures = "0.3"
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "1.0"
anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
@@ -57,3 +69,7 @@ lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"
[dev-dependencies]
serial_test = "3"
tempfile = "3"

19
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>CC Switch Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ccswitch</string>
</array>
</dict>
</array>
</dict>
</plist>

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

@@ -2,7 +2,77 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
/// MCP 配置单客户端维度claude 或 codex 下的一组服务器)
use crate::services::skill::SkillStore;
/// MCP 服务器应用状态(标记应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct McpApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl McpApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
}
}
/// MCP 服务器定义v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
pub server: serde_json::Value,
pub apps: McpApps,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docs: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
/// MCP 配置单客户端维度v3.6.x 及以前,保留用于向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
@@ -10,25 +80,72 @@ pub struct McpConfig {
pub servers: HashMap<String, serde_json::Value>,
}
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
impl McpConfig {
/// 检查配置是否为空
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
}
/// MCP 根配置v3.7.0 新旧结构并存)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRoot {
#[serde(default)]
/// 统一的 MCP 服务器存储v3.7.0+
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<HashMap<String, McpServer>>,
/// 旧的分应用存储v3.6.x 及以前,保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(default)]
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
}
impl Default for McpRoot {
fn default() -> Self {
Self {
// v3.7.0+ 默认使用新的统一结构(空 HashMap
servers: Some(HashMap::new()),
// 旧结构保持空,仅用于反序列化旧配置时的迁移
claude: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
}
}
}
/// Prompt 配置:单客户端维度
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptConfig {
#[serde(default)]
pub prompts: HashMap<String, crate::prompt::Prompt>,
}
/// Prompt 根:按客户端分开维护
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptRoot {
#[serde(default)]
pub claude: PromptConfig,
#[serde(default)]
pub codex: PromptConfig,
#[serde(default)]
pub gemini: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::error::AppError;
use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager;
/// 应用类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
Codex,
Gemini, // 新增
}
impl AppType {
@@ -36,6 +153,7 @@ impl AppType {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini", // 新增
}
}
}
@@ -48,15 +166,49 @@ impl FromStr for AppType {
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), // 新增
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
)),
}
}
}
/// 通用配置片段(按应用分治)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
}
impl CommonConfigSnippets {
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
}
}
/// 设置指定应用的通用配置片段
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
}
}
}
/// 多应用配置结构(向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiAppConfig {
@@ -68,6 +220,18 @@ pub struct MultiAppConfig {
/// MCP 配置(按客户端分治)
#[serde(default)]
pub mcp: McpRoot,
/// Prompt 配置(按客户端分治)
#[serde(default)]
pub prompts: PromptRoot,
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_common_config_snippet: Option<String>,
}
fn default_version() -> u32 {
@@ -79,11 +243,16 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
Self {
version: 2,
apps,
mcp: McpRoot::default(),
prompts: PromptRoot::default(),
skills: SkillStore::default(),
common_config_snippets: CommonConfigSnippets::default(),
claude_common_config_snippet: None,
}
}
}
@@ -94,8 +263,12 @@ impl MultiAppConfig {
let config_path = get_app_config_path();
if !config_path.exists() {
log::info!("配置文件不存在,创建新的多应用配置");
return Ok(Self::default());
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
// 使用新的方法,支持自动导入提示词
let config = Self::default_with_auto_import()?;
// 立即保存到磁盘
config.save()?;
return Ok(config);
}
// 尝试读取文件
@@ -121,8 +294,73 @@ impl MultiAppConfig {
));
}
let has_skills_in_config = value
.as_object()
.is_some_and(|map| map.contains_key("skills"));
// 解析 v2 结构
serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
let mut config: Self =
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
let mut updated = false;
if !has_skills_in_config {
let skills_path = get_app_config_dir().join("skills.json");
if skills_path.exists() {
match std::fs::read_to_string(&skills_path) {
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
Ok(store) => {
config.skills = store;
updated = true;
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
}
Err(e) => {
log::warn!("解析旧版 skills.json 失败: {e}");
}
},
Err(e) => {
log::warn!("读取旧版 skills.json 失败: {e}");
}
}
}
}
// 确保 gemini 应用存在(兼容旧配置文件)
if !config.apps.contains_key("gemini") {
config
.apps
.insert("gemini".to_string(), ProviderManager::default());
updated = true;
}
// 执行 MCP 迁移v3.6.x → v3.7.0
let migrated = config.migrate_mcp_to_unified()?;
if migrated {
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
updated = true;
}
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
if imported_prompts {
updated = true;
}
// 迁移通用配置片段claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!(
"迁移通用配置claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true;
}
if updated {
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
config.save()?;
}
Ok(config)
}
/// 保存配置到文件
@@ -132,7 +370,7 @@ impl MultiAppConfig {
if config_path.exists() {
let backup_path = get_app_config_dir().join("config.json.bak");
if let Err(e) = copy_file(&config_path, &backup_path) {
log::warn!("备份 config.json 到 .bak 失败: {}", e);
log::warn!("备份 config.json 到 .bak 失败: {e}");
}
}
@@ -163,6 +401,7 @@ impl MultiAppConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
}
}
@@ -171,6 +410,451 @@ impl MultiAppConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
}
}
/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
log::info!("首次启动,创建默认配置并检测提示词文件");
let mut config = Self::default();
// 为每个应用尝试自动导入提示词
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Ok(config)
}
/// 已存在配置文件时的 Prompt 自动导入逻辑
///
/// 适用于「老版本已经生成过 config.json但当时还没有 Prompt 功能」的升级场景。
/// 判定规则:
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md
///
/// 返回值:
/// - Ok(true) 表示至少有一个应用成功导入了提示词
/// - Ok(false) 表示无需导入或未导入任何内容
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
{
return Ok(false);
}
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
}
}
Ok(imported)
}
/// 检查并自动导入单个应用的提示词文件
///
/// 返回值:
/// - Ok(true) 表示成功导入了非空文件
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
let file_path = prompt_file_path(&app)?;
// 检查文件是否存在
if !file_path.exists() {
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
return Ok(false);
}
// 读取文件内容
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
return Ok(false); // 失败时不中断,继续处理其他应用
}
};
// 检查内容是否为空
if content.trim().is_empty() {
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
return Ok(false);
}
log::info!("发现提示词文件,自动导入: {file_path:?}");
// 创建提示词对象
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let id = format!("auto-imported-{timestamp}");
let prompt = crate::prompt::Prompt {
id: id.clone(),
name: format!(
"Auto-imported Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Automatically imported on first launch".to_string()),
enabled: true, // 自动启用
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
// 插入到对应的应用配置中
let prompts = match app {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
};
prompts.insert(id, prompt);
log::info!("自动导入完成: {}", app.as_str());
Ok(true)
}
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
///
/// 迁移策略:
/// 1. 检查是否已经迁移mcp.servers 是否存在)
/// 2. 收集所有应用的 MCP按 ID 去重合并
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
/// 4. 清空旧的分应用配置
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
// 检查是否已经是新结构
if self.mcp.servers.is_some() {
log::debug!("MCP 配置已是统一结构,跳过迁移");
return Ok(false);
}
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
};
for (id, entry) in old_servers {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if let Some(existing) = unified_servers.get_mut(id) {
// 该 ID 已存在,合并 apps 字段
existing.apps.set_enabled_for(&app, enabled);
// 检测配置冲突(同 ID 但配置不同)
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
conflicts.push(format!(
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
app.as_str()
));
}
} else {
// 首次遇到该 MCP创建新条目
let name = entry
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(id)
.to_string();
let server = entry
.get("server")
.cloned()
.unwrap_or(serde_json::json!({}));
let description = entry
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let homepage = entry
.get("homepage")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let docs = entry
.get("docs")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tags = entry
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut apps = McpApps::default();
apps.set_enabled_for(&app, enabled);
unified_servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name,
server,
apps,
description,
homepage,
docs,
tags,
},
);
}
}
}
// 记录冲突警告
if !conflicts.is_empty() {
log::warn!("MCP 迁移过程中检测到配置冲突:");
for conflict in &conflicts {
log::warn!(" - {conflict}");
}
}
log::info!(
"MCP 迁移完成,共迁移 {} 个服务器{}",
unified_servers.len(),
if !conflicts.is_empty() {
format!("(存在 {} 个冲突)", conflicts.len())
} else {
String::new()
}
);
// 替换为新结构
self.mcp.servers = Some(unified_servers);
// 清空旧的分应用配置
self.mcp.claude = McpConfig::default();
self.mcp.codex = McpConfig::default();
self.mcp.gemini = McpConfig::default();
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::fs;
use tempfile::TempDir;
struct TempHome {
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
Self {
dir,
original_home,
original_userprofile,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
}
}
fn write_prompt_file(app: AppType, content: &str) {
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, content).expect("write prompt");
}
#[test]
#[serial]
fn auto_imports_existing_prompt_when_config_missing() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "# hello");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.claude.prompts.len(), 1);
let prompt = config
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists");
assert!(prompt.enabled);
assert_eq!(prompt.content, "# hello");
let config_path = crate::config::get_app_config_path();
assert!(
config_path.exists(),
"auto import should persist config to disk"
);
}
#[test]
#[serial]
fn skips_empty_prompt_files_during_import() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, " \n ");
let config = MultiAppConfig::load().expect("load config");
assert!(
config.prompts.claude.prompts.is_empty(),
"empty files must be ignored"
);
}
#[test]
#[serial]
fn auto_import_happens_only_once() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "first version");
let first = MultiAppConfig::load().expect("load config");
assert_eq!(first.prompts.claude.prompts.len(), 1);
let claude_prompt = first
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists")
.content
.clone();
assert_eq!(claude_prompt, "first version");
// 覆盖文件内容,但保留 config.json
write_prompt_file(AppType::Claude, "second version");
let second = MultiAppConfig::load().expect("load config again");
assert_eq!(second.prompts.claude.prompts.len(), 1);
let prompt = second
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists");
assert_eq!(
prompt.content, "first version",
"should not re-import when config already exists"
);
}
#[test]
#[serial]
fn auto_imports_gemini_prompt_on_first_launch() {
let _home = TempHome::new();
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.gemini.prompts.len(), 1);
let prompt = config
.prompts
.gemini
.prompts
.values()
.next()
.expect("gemini prompt exists");
assert!(prompt.enabled, "gemini prompt should be enabled");
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
assert_eq!(
prompt.description,
Some("Automatically imported on first launch".to_string())
);
}
#[test]
#[serial]
fn auto_imports_all_three_apps_prompts() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "# Claude prompt");
write_prompt_file(AppType::Codex, "# Codex prompt");
write_prompt_file(AppType::Gemini, "# Gemini prompt");
let config = MultiAppConfig::load().expect("load config");
// 验证所有三个应用的提示词都被导入
assert_eq!(config.prompts.claude.prompts.len(), 1);
assert_eq!(config.prompts.codex.prompts.len(), 1);
assert_eq!(config.prompts.gemini.prompts.len(), 1);
// 验证所有提示词都被启用
assert!(
config
.prompts
.claude
.prompts
.values()
.next()
.unwrap()
.enabled
);
assert!(
config
.prompts
.codex
.prompts
.values()
.next()
.unwrap()
.enabled
);
assert!(
config
.prompts
.gemini
.prompts
.values()
.next()
.unwrap()
.enabled
);
}
}

View File

@@ -30,7 +30,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
let store = match app.store_builder("app_paths.json").build() {
Ok(store) => store,
Err(e) => {
log::warn!("无法创建 Store: {}", e);
log::warn!("无法创建 Store: {e}");
return None;
}
};
@@ -46,21 +46,17 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
if !path.exists() {
log::warn!(
"Store 中配置的 app_config_dir 不存在: {:?}\n\
将使用默认路径。",
path
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
将使用默认路径。"
);
return None;
}
log::info!("使用 Store 中的 app_config_dir: {:?}", path);
log::info!("使用 Store 中的 app_config_dir: {path:?}");
Some(path)
}
Some(_) => {
log::warn!(
"Store 中的 {} 类型不正确,应为字符串",
STORE_KEY_APP_CONFIG_DIR
);
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
None
}
None => None,
@@ -82,14 +78,14 @@ pub fn set_app_config_dir_to_store(
let store = app
.store_builder("app_paths.json")
.build()
.map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
match path {
Some(p) => {
let trimmed = p.trim();
if !trimmed.is_empty() {
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
} else {
store.delete(STORE_KEY_APP_CONFIG_DIR);
log::info!("已从 Store 中删除 app_config_dir 配置");
@@ -103,7 +99,7 @@ pub fn set_app_config_dir_to_store(
store
.save()
.map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
refresh_app_config_dir_override(app);
Ok(())

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

@@ -37,7 +37,7 @@ fn ensure_mcp_override_migrated() {
if let Some(parent) = new_path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
log::warn!("创建 MCP 目录失败: {}", err);
log::warn!("创建 MCP 目录失败: {err}");
return;
}
}
@@ -118,9 +118,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
let t_opt = spec.get("type").and_then(|x| x.as_str());
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio''http'(或省略表示 stdio".into(),
"MCP 服务器 type 必须是 'stdio''http' 或 'sse'(或省略表示 stdio".into(),
));
}
@@ -134,13 +135,15 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
}
}
// http 类型必须有 url
if is_http {
// http/sse 类型必须有 url
if is_http || is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
return Err(AppError::McpValidation(if is_http {
"http 类型的 MCP 服务器缺少 url 字段".into()
} else {
"sse 类型的 MCP 服务器缺少 url 字段".into()
}));
}
}
@@ -231,6 +234,23 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
Ok(false)
}
/// 读取 ~/.claude.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(std::collections::HashMap::new());
}
let root = read_json_value(&path)?;
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)
}
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
@@ -250,14 +270,13 @@ pub fn set_mcp_servers_map(
map.clone()
} else {
return Err(AppError::McpValidation(format!(
"MCP 服务器 '{}' 不是对象",
id
"MCP 服务器 '{id}' 不是对象"
)));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
})?;
obj = server_obj;
}

View File

@@ -80,7 +80,7 @@ pub fn write_claude_config() -> Result<bool, AppError> {
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
} else {
Ok(false)
@@ -114,7 +114,7 @@ pub fn clear_claude_config() -> Result<bool, AppError> {
let serialized =
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
}

View File

@@ -37,8 +37,8 @@ pub fn get_codex_provider_paths(
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
(auth_path, config_path)
}

View File

@@ -29,6 +29,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::Gemini => {
let env_path = crate::gemini_config::get_gemini_env_path();
let exists = env_path.exists();
let path = crate::gemini_config::get_gemini_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -44,6 +53,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -55,16 +65,17 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
@@ -87,14 +98,14 @@ pub async fn pick_directory(
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
@@ -114,13 +125,116 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
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())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
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() {
None
} else {
Some(snippet)
};
guard.save().map_err(|e| e.to_string())?;
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
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())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
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 => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate
// 注意TOML 验证较为复杂,暂时跳过
}
}
}
guard.common_config_snippets.set(
&app,
if snippet.trim().is_empty() {
None
} else {
Some(snippet)
},
);
guard.save().map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,39 @@
use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
use crate::store::AppState;
use tauri::State;
/// Parse a deep link URL and return the parsed request for frontend confirmation
#[tauri::command]
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
log::info!("Parsing deep link URL: {url}");
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(
state: State<AppState>,
request: DeepLinkImportRequest,
) -> Result<String, String> {
log::info!(
"Importing provider from deep link: {} for app {}",
request.name,
request.app
);
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
log::info!("Successfully imported provider with ID: {provider_id}");
Ok(provider_id)
}

View File

@@ -0,0 +1,22 @@
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
use crate::services::env_manager::{
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
};
/// Check environment variable conflicts for a specific app
#[tauri::command]
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
check_conflicts(&app)
}
/// Delete environment variables with backup
#[tauri::command]
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
delete_vars(conflicts)
}
/// Restore environment variables from backup file
#[tauri::command]
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
restore_from_backup(backup_path)
}

View File

@@ -24,7 +24,7 @@ pub async fn export_config_to_file(
}))
})
.await
.map_err(|e| format!("导出配置失败: {}", e))?
.map_err(|e| format!("导出配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())
}
@@ -39,7 +39,7 @@ pub async fn import_config_from_file(
ConfigService::load_config_for_import(&path_buf)
})
.await
.map_err(|e| format!("导入配置失败: {}", e))?
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())?;
{

View File

@@ -50,6 +50,7 @@ pub struct McpConfigResponse {
use std::str::FromStr;
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: String,
@@ -66,6 +67,7 @@ pub async fn get_mcp_config(
}
/// 在 config.json 中新增或更新一个 MCP 服务器定义
/// [已废弃] 该命令仍然使用旧的分应用API会转换为统一结构
#[tauri::command]
pub async fn upsert_mcp_server_in_config(
state: State<'_, AppState>,
@@ -74,8 +76,59 @@ pub async fn upsert_mcp_server_in_config(
spec: serde_json::Value,
sync_other_side: Option<bool>,
) -> Result<bool, String> {
use crate::app_config::McpServer;
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false))
// 读取现有的服务器(如果存在)
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 mut new_server = if let Some(mut existing) = existing_server {
// 更新现有服务器
existing.server = spec.clone();
existing.apps.set_enabled_for(&app_ty, true);
existing
} else {
// 创建新服务器
let mut apps = crate::app_config::McpApps::default();
apps.set_enabled_for(&app_ty, true);
// 尝试从 spec 中提取 name否则使用 id
let name = spec
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
McpServer {
id: id.clone(),
name,
server: spec,
apps,
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
}
};
// 如果 sync_other_side 为 true也启用其他应用
if sync_other_side.unwrap_or(false) {
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
}
McpService::upsert_server(&state, new_server)
.map(|_| true)
.map_err(|e| e.to_string())
}
@@ -83,15 +136,15 @@ pub async fn upsert_mcp_server_in_config(
#[tauri::command]
pub async fn delete_mcp_server_in_config(
state: State<'_, AppState>,
app: String,
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
id: String,
) -> Result<bool, String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string())
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 设置启用状态并同步到客户端配置
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: String,
@@ -102,30 +155,43 @@ pub async fn set_mcp_enabled(
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
// ============================================================================
// v3.7.0 新增:统一 MCP 管理命令
// ============================================================================
use crate::app_config::McpServer;
/// 获取所有 MCP 服务器(统一结构)
#[tauri::command]
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Claude)
.map(|_| true)
.map_err(|e| e.to_string())
pub async fn get_mcp_servers(
state: State<'_, AppState>,
) -> Result<HashMap<String, McpServer>, String> {
McpService::get_all_servers(&state).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
/// 添加或更新 MCP 服务器
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Codex)
.map(|_| true)
.map_err(|e| e.to_string())
pub async fn upsert_mcp_server(
state: State<'_, AppState>,
server: McpServer,
) -> Result<(), String> {
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
/// 删除 MCP 服务器
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_claude(&state).map_err(|e| e.to_string())
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
/// 切换 MCP 服务器在指定应用的启用状态
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_codex(&state).map_err(|e| e.to_string())
pub async fn toggle_mcp_app(
state: State<'_, AppState>,
server_id: String,
app: String,
enabled: bool,
) -> Result<(), String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}

View File

@@ -10,12 +10,12 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
let url = if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://{}", url)
format!("https://{url}")
};
app.opener()
.open_url(&url, None::<String>)
.map_err(|e| format!("打开链接失败: {}", e))?;
.map_err(|e| format!("打开链接失败: {e}"))?;
Ok(true)
}
@@ -29,7 +29,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
.map_err(|e| format!("打开更新页面失败: {e}"))?;
Ok(true)
}
@@ -37,7 +37,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {

View File

@@ -1,17 +1,25 @@
#![allow(non_snake_case)]
mod config;
mod deeplink;
mod env;
mod import_export;
mod mcp;
mod misc;
mod plugin;
mod prompt;
mod provider;
mod settings;
pub mod skill;
pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use settings::*;
pub use skill::*;

View File

@@ -0,0 +1,64 @@
use std::collections::HashMap;
use std::str::FromStr;
use tauri::State;
use crate::app_config::AppType;
use crate::prompt::Prompt;
use crate::services::PromptService;
use crate::store::AppState;
#[tauri::command]
pub async fn get_prompts(
app: String,
state: State<'_, AppState>,
) -> Result<HashMap<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())
}
#[tauri::command]
pub async fn upsert_prompt(
app: String,
id: String,
prompt: Prompt,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_prompt(
app: String,
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn enable_prompt(
app: String,
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_prompt_from_file(
app: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_current_file_content(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

@@ -0,0 +1,176 @@
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
pub struct SkillServiceState(pub Arc<SkillService>);
#[tauri::command]
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()
};
service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn install_skill(
directory: String,
service: State<'_, SkillServiceState>,
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 skills = service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.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(|| {
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()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
.0
.install_skill(directory.clone(), repo)
.await
.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 {
installed: true,
installed_at: Utc::now(),
},
);
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
service
.0
.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())?;
Ok(true)
}
#[tauri::command]
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())
}
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
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())?;
Ok(true)
}
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
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())?;
Ok(true)
}

View File

@@ -33,7 +33,7 @@ fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
return None;
}
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
Some(parent.join(format!("{}.json", file_name)))
Some(parent.join(format!("{file_name}.json")))
}
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
@@ -95,7 +95,7 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>)
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{}.json", base_name))
get_claude_config_dir().join(format!("settings-{base_name}.json"))
}
/// 读取 JSON 配置文件
@@ -149,7 +149,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{}.tmp.{}", file_name, ts));
tmp.push(format!("{file_name}.tmp.{ts}"));
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;

866
src-tauri/src/deeplink.rs Normal file
View File

@@ -0,0 +1,866 @@
/// Deep link import functionality for CC Switch
///
/// This module implements the ccswitch:// protocol for importing provider configurations
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use url::Url;
/// Deep link import request model
/// Represents a parsed ccswitch:// URL ready for processing
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
pub version: String,
/// Resource type to import (e.g., "provider")
pub resource: String,
/// Target application (claude/codex/gemini)
pub app: String,
/// Provider name
pub name: String,
/// Provider homepage URL
pub homepage: String,
/// API endpoint/base URL
pub endpoint: String,
/// API key
pub api_key: String,
/// Optional model name
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 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
///
/// Expected format:
/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
// Parse URL
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
// Validate scheme
let scheme = url.scheme();
if scheme != "ccswitch" {
return Err(AppError::InvalidInput(format!(
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
)));
}
// Extract version from host
let version = url
.host_str()
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
.to_string();
// Validate version
if version != "v1" {
return Err(AppError::InvalidInput(format!(
"Unsupported protocol version: {version}"
)));
}
// Extract path (should be "/import")
let path = url.path();
if path != "/import" {
return Err(AppError::InvalidInput(format!(
"Invalid path: expected '/import', got '{path}'"
)));
}
// Parse query parameters
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
// Extract and validate resource type
let resource = params
.get("resource")
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
.clone();
if resource != "provider" {
return Err(AppError::InvalidInput(format!(
"Unsupported resource type: {resource}"
)));
}
// Extract required fields
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' 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();
// 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,
app,
name,
homepage,
endpoint,
api_key,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
config,
config_format,
config_url,
})
}
/// Validate that a string is a valid HTTP(S) URL
fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(AppError::InvalidInput(format!(
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
)));
}
Ok(())
}
/// Import a provider from a deep link request
///
/// This function:
/// 1. Validates the request
/// 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(&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, &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 = merged_request
.name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase();
provider.id = format!("{sanitized_name}-{timestamp}");
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type, provider)?;
Ok(provider_id)
}
/// Build a Provider structure from a deep link request
fn build_provider_from_request(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
use serde_json::json;
let settings_config = match app_type {
AppType::Claude => {
// Claude configuration structure
let mut env = serde_json::Map::new();
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
// 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 => {
// Codex configuration structure
// For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。
//
// 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致,
// 再根据深链接参数注入 base_url / model避免出现“只有 base_url 行”的极简配置,
// 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。
// 1. 生成一个适合作为 model_provider 名的安全标识
// 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致:
// - 转小写
// - 非 [a-z0-9_] 统一替换为下划线
// - 去掉首尾下划线
// - 若结果为空,则使用 "custom"
let clean_provider_name = {
let raw: String = request.name.chars().filter(|c| !c.is_control()).collect();
let lower = raw.to_lowercase();
let mut key: String = lower
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' | '_' => c,
_ => '_',
})
.collect();
// 去掉首尾下划线
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
};
// 2. 模型名称:优先使用 deeplink 中的 model否则退回到 Codex 默认模型
let model_name = request
.model
.as_deref()
.unwrap_or("gpt-5-codex")
.to_string();
// 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠
let endpoint = request.endpoint.trim().trim_end_matches('/').to_string();
// 4. 组装 config.toml 内容
// 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告
let config_toml = format!(
r#"model_provider = "{clean_provider_name}"
model = "{model_name}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.{clean_provider_name}]
name = "{clean_provider_name}"
base_url = "{endpoint}"
wire_api = "responses"
requires_openai_auth = true
"#
);
json!({
"auth": {
"OPENAI_API_KEY": request.api_key,
},
"config": config_toml
})
}
AppType::Gemini => {
// Gemini configuration structure (.env format)
let mut env = serde_json::Map::new();
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint),
);
// Add model if provided
if let Some(model) = &request.model {
env.insert("GEMINI_MODEL".to_string(), json!(model));
}
json!({ "env": env })
}
};
let provider = Provider {
id: String::new(), // Will be generated by ProviderService
name: request.name.clone(),
settings_config,
website_url: Some(request.homepage.clone()),
category: None,
created_at: None,
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::*;
#[test]
fn test_parse_valid_claude_deeplink() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.version, "v1");
assert_eq!(request.resource, "provider");
assert_eq!(request.app, "claude");
assert_eq!(request.name, "Test Provider");
assert_eq!(request.homepage, "https://example.com");
assert_eq!(request.endpoint, "https://api.example.com");
assert_eq!(request.api_key, "sk-test-123");
}
#[test]
fn test_parse_deeplink_with_notes() {
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123&notes=Test%20notes";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
}
#[test]
fn test_parse_unsupported_version() {
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported protocol version"));
}
#[test]
fn test_parse_missing_required_field() {
// 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 'name' parameter"));
}
#[test]
fn test_validate_invalid_url() {
let result = validate_url("not-a-url", "test");
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_scheme() {
let result = validate_url("ftp://example.com", "test");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be http or https"));
}
#[test]
fn test_build_gemini_provider_with_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
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();
// Verify provider basic info
assert_eq!(provider.name, "Test Gemini");
assert_eq!(
provider.website_url,
Some("https://example.com".to_string())
);
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
}
#[test]
fn test_build_gemini_provider_without_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
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();
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
// 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

@@ -94,3 +94,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

@@ -0,0 +1,656 @@
use crate::config::write_text_file;
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".gemini")
}
/// 获取 Gemini .env 文件路径
pub fn get_gemini_env_path() -> PathBuf {
get_gemini_dir().join(".env")
}
/// 解析 .env 文件内容为键值对
///
/// 此函数宽松地解析 .env 文件,跳过无效行。
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 解析 KEY=VALUE
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
map.insert(key, value);
}
}
}
map
}
/// 严格解析 .env 文件内容,返回详细的错误信息
///
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
/// 包含行号和详细的错误信息。
///
/// # 错误
///
/// 返回 `AppError` 如果遇到以下情况:
/// - 行不包含 `=` 分隔符
/// - Key 为空或包含无效字符
/// - Key 不符合环境变量命名规范
///
/// # 使用场景
///
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
/// 可用于:
/// - 配置导入验证
/// - CLI 工具的严格模式
/// - 配置文件错误诊断
///
/// 已有完整的测试覆盖,可直接使用。
#[allow(dead_code)]
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
let mut map = HashMap::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
let line_number = line_num + 1; // 行号从 1 开始
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 检查是否包含 =
if !line.contains('=') {
return Err(AppError::localized(
"gemini.env.parse_error.no_equals",
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
));
}
// 解析 KEY=VALUE
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
// 验证 key 不为空
if key.is_empty() {
return Err(AppError::localized(
"gemini.env.parse_error.empty_key",
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
));
}
// 验证 key 只包含字母、数字和下划线
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(AppError::localized(
"gemini.env.parse_error.invalid_key",
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
));
}
map.insert(key.to_string(), value.to_string());
}
}
Ok(map)
}
/// 将键值对序列化为 .env 格式
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
let mut lines = Vec::new();
// 按键排序以保证输出稳定
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = map.get(key) {
lines.push(format!("{key}={value}"));
}
}
lines.join("\n")
}
/// 读取 Gemini .env 文件
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
let path = get_gemini_env_path();
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(parse_env_file(&content))
}
/// 写入 Gemini .env 文件(原子操作)
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
let path = get_gemini_env_path();
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
// 设置目录权限为 700仅所有者可读写执行
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)
.map_err(|e| AppError::io(parent, e))?
.permissions();
perms.set_mode(0o700);
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
}
}
let content = serialize_env_file(map);
write_text_file(&path, &content)?;
// 设置文件权限为 600仅所有者可读写
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)
.map_err(|e| AppError::io(&path, e))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
}
Ok(())
}
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
let mut json_map = serde_json::Map::new();
for (key, value) in env_map {
json_map.insert(key.clone(), Value::String(value.clone()));
}
serde_json::json!({ "env": json_map })
}
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
let mut env_map = HashMap::new();
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
for (key, value) in env_obj {
if let Some(val_str) = value.as_str() {
env_map.insert(key.clone(), val_str.to_string());
}
}
}
Ok(env_map)
}
/// 验证 Gemini 配置的基本结构
///
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
///
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
// 只验证基本结构,不强制要求 GEMINI_API_KEY
// 如果有 env 字段,验证它是一个对象
if let Some(env) = settings.get("env") {
if !env.is_object() {
return Err(AppError::localized(
"gemini.validation.invalid_env",
"Gemini 配置格式错误: env 必须是对象",
"Gemini config invalid: env must be an object",
));
}
}
// 如果有 config 字段,验证它是对象或 null
if let Some(config) = settings.get("config") {
if !(config.is_object() || config.is_null()) {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象",
"Gemini config invalid: config must be an object",
));
}
}
Ok(())
}
/// 严格验证 Gemini 配置(要求必需字段)
///
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
/// 对于需要 API Key 的供应商(如 PackyCode会验证 GEMINI_API_KEY 字段。
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
// 先做基础格式验证(包含 env/config 类型)
validate_gemini_settings(settings)?;
let env_map = json_to_env(settings)?;
// 如果 env 为空,表示使用 OAuth如 Google 官方),跳过验证
if env_map.is_empty() {
return Ok(());
}
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
if !env_map.contains_key("GEMINI_API_KEY") {
return Err(AppError::localized(
"gemini.validation.missing_api_key",
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
"Gemini config missing required field: GEMINI_API_KEY",
));
}
Ok(())
}
/// 获取 Gemini settings.json 文件路径
///
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
pub fn get_gemini_settings_path() -> PathBuf {
get_gemini_dir().join("settings.json")
}
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
///
/// 此函数会:
/// 1. 读取现有的 settings.json如果存在
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
/// 3. 原子性写入文件
///
/// # 参数
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal"
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
let settings_path = get_gemini_settings_path();
// 确保目录存在
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
// 读取现有的 settings.json如果存在
let mut settings_content = if settings_path.exists() {
let content =
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// 只更新 security.auth.selectedType 字段
if let Some(obj) = settings_content.as_object_mut() {
let security = obj
.entry("security")
.or_insert_with(|| serde_json::json!({}));
if let Some(security_obj) = security.as_object_mut() {
let auth = security_obj
.entry("auth")
.or_insert_with(|| serde_json::json!({}));
if let Some(auth_obj) = auth.as_object_mut() {
auth_obj.insert(
"selectedType".to_string(),
Value::String(selected_type.to_string()),
);
}
}
}
// 写入文件
crate::config::write_json_file(&settings_path, &settings_content)?;
Ok(())
}
/// 为 Packycode Gemini 供应商写入 settings.json
///
/// 设置 `~/.gemini/settings.json` 中的:
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// 保留文件中的其他所有字段。
pub fn write_packycode_settings() -> Result<(), AppError> {
update_selected_type("gemini-api-key")
}
/// 为 Google 官方 Gemini 供应商写入 settings.jsonOAuth 模式)
///
/// 设置 `~/.gemini/settings.json` 中的:
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "oauth-personal"
/// }
/// }
/// }
/// ```
///
/// 保留文件中的其他所有字段。
pub fn write_google_oauth_settings() -> Result<(), AppError> {
update_selected_type("oauth-personal")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_env_file() {
let content = r#"
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
let map = parse_env_file(content);
assert_eq!(map.len(), 3);
assert_eq!(
map.get("GOOGLE_GEMINI_BASE_URL"),
Some(&"https://example.com".to_string())
);
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
assert_eq!(
map.get("GEMINI_MODEL"),
Some(&"gemini-3-pro-preview".to_string())
);
}
#[test]
fn test_serialize_env_file() {
let mut map = HashMap::new();
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
map.insert(
"GEMINI_MODEL".to_string(),
"gemini-3-pro-preview".to_string(),
);
let content = serialize_env_file(&map);
assert!(content.contains("GEMINI_API_KEY=sk-test"));
assert!(content.contains("GEMINI_MODEL=gemini-3-pro-preview"));
}
#[test]
fn test_env_json_conversion() {
let mut env_map = HashMap::new();
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
let json = env_to_json(&env_map);
let converted = json_to_env(&json).unwrap();
assert_eq!(
converted.get("GEMINI_API_KEY"),
Some(&"test-key".to_string())
);
}
#[test]
fn test_parse_env_file_strict_success() {
// 测试严格模式下正常解析
let content = r#"
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
let result = parse_env_file_strict(content);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.len(), 3);
assert_eq!(
map.get("GOOGLE_GEMINI_BASE_URL"),
Some(&"https://example.com".to_string())
);
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
assert_eq!(
map.get("GEMINI_MODEL"),
Some(&"gemini-3-pro-preview".to_string())
);
}
#[test]
fn test_parse_env_file_strict_missing_equals() {
// 测试严格模式下检测缺少 = 的行
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
INVALID_LINE_WITHOUT_EQUALS
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
}
#[test]
fn test_parse_env_file_strict_empty_key() {
// 测试严格模式下检测空 key
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
=value_without_key
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("empty") || err_msg.contains(""));
}
#[test]
fn test_parse_env_file_strict_invalid_key_characters() {
// 测试严格模式下检测无效字符(如空格、特殊符号)
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
INVALID KEY WITH SPACES=value
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
}
#[test]
fn test_parse_env_file_lax_vs_strict() {
// 测试宽松模式和严格模式的差异
let content = "VALID_KEY=value
INVALID LINE
KEY_WITH-DASH=value";
// 宽松模式:跳过无效行,继续解析
let lax_result = parse_env_file(content);
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
// 严格模式:遇到无效行立即返回错误
let strict_result = parse_env_file_strict(content);
assert!(strict_result.is_err());
}
#[test]
fn test_packycode_settings_structure() {
// 验证 Packycode settings.json 的结构正确
let settings_content = serde_json::json!({
"security": {
"auth": {
"selectedType": "gemini-api-key"
}
}
});
assert_eq!(
settings_content["security"]["auth"]["selectedType"],
"gemini-api-key"
);
}
#[test]
fn test_packycode_settings_merge() {
// 测试合并逻辑:应该保留其他字段
let mut existing_settings = serde_json::json!({
"otherField": "should-be-kept",
"security": {
"otherSetting": "also-kept",
"auth": {
"otherAuth": "preserved"
}
}
});
// 模拟更新 selectedType
if let Some(obj) = existing_settings.as_object_mut() {
let security = obj
.entry("security")
.or_insert_with(|| serde_json::json!({}));
if let Some(security_obj) = security.as_object_mut() {
let auth = security_obj
.entry("auth")
.or_insert_with(|| serde_json::json!({}));
if let Some(auth_obj) = auth.as_object_mut() {
auth_obj.insert(
"selectedType".to_string(),
Value::String("gemini-api-key".to_string()),
);
}
}
}
// 验证所有字段都被保留
assert_eq!(existing_settings["otherField"], "should-be-kept");
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
assert_eq!(
existing_settings["security"]["auth"]["otherAuth"],
"preserved"
);
assert_eq!(
existing_settings["security"]["auth"]["selectedType"],
"gemini-api-key"
);
}
#[test]
fn test_google_oauth_settings_structure() {
// 验证 Google OAuth settings.json 的结构正确
let settings_content = serde_json::json!({
"security": {
"auth": {
"selectedType": "oauth-personal"
}
}
});
assert_eq!(
settings_content["security"]["auth"]["selectedType"],
"oauth-personal"
);
}
#[test]
fn test_validate_empty_env_for_oauth() {
// 测试空 envGoogle 官方 OAuth可以通过基本验证
let settings = serde_json::json!({
"env": {}
});
assert!(validate_gemini_settings(&settings).is_ok());
// 严格验证也应该通过(空 env 表示 OAuth
assert!(validate_gemini_settings_strict(&settings).is_ok());
}
#[test]
fn test_validate_env_with_api_key() {
// 测试有 API Key 的配置可以通过验证
let settings = serde_json::json!({
"env": {
"GEMINI_API_KEY": "sk-test123",
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});
assert!(validate_gemini_settings(&settings).is_ok());
assert!(validate_gemini_settings_strict(&settings).is_ok());
}
#[test]
fn test_validate_env_without_api_key_relaxed() {
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
let settings = serde_json::json!({
"env": {
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});
// 基本验证应该通过(允许稍后填写 API Key
assert!(validate_gemini_settings(&settings).is_ok());
// 严格验证应该失败(切换时要求完整配置)
assert!(validate_gemini_settings_strict(&settings).is_err());
}
#[test]
fn test_validate_invalid_env_type() {
// 测试 env 不是对象时会失败
let settings = serde_json::json!({
"env": "invalid_string"
});
assert!(validate_gemini_settings(&settings).is_err());
}
}

134
src-tauri/src/gemini_mcp.rs Normal file
View File

@@ -0,0 +1,134 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::atomic_write;
use crate::error::AppError;
use crate::gemini_config::get_gemini_settings_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json
fn user_config_path() -> PathBuf {
get_gemini_settings_path()
}
fn read_json_value(path: &Path) -> Result<Value, AppError> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
Ok(value)
}
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json =
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(std::collections::HashMap::new());
}
let root = read_json_value(&path)?;
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)
}
/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(AppError::McpValidation(format!(
"MCP 服务器 '{id}' 不是对象"
)));
};
// 提取 server 字段(如果存在)
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
})?;
obj = server_obj;
}
// 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");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.gemini/settings.json 根必须是对象".into()))?;
obj.insert("mcpServers".into(), Value::Object(out));
}
write_json_value(&path, &root)?;
Ok(())
}

View File

@@ -1,32 +1,48 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_mcp;
mod init_status;
mod mcp;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod services;
mod settings;
mod store;
mod usage_script;
pub use app_config::{AppType, MultiAppConfig};
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex,
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
SpeedtestService,
};
pub use provider::Provider;
pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService};
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use std::sync::Arc;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
@@ -59,6 +75,129 @@ impl TrayTexts {
}
}
struct TrayAppSection {
app_type: AppType,
prefix: &'static str,
header_id: &'static str,
empty_id: &'static str,
header_label: &'static str,
log_name: &'static str,
}
const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
@@ -74,131 +213,29 @@ fn create_tray_menu(
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
// Sort providers by sortIndex, then by createdAt, then by name
let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
// Priority 1: sortIndex
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
// Priority 2: createdAt
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
// Priority 3: name
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
// Sort providers by sortIndex, then by createdAt, then by name
let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
// Priority 1: sortIndex
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
// Priority 2: createdAt
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
// Priority 3: name
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?;
menu_builder = menu_builder.item(&empty_hint);
}
for section in TRAY_SECTIONS.iter() {
menu_builder = append_provider_section(
app,
menu_builder,
config.get_manager(&section.app_type),
section,
&tray_texts,
)?;
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {}", e)))
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
@@ -210,17 +247,17 @@ fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
log::warn!("设置激活策略失败: {err}");
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
log::info!("处理托盘菜单事件: {event_id}");
match event_id {
"show_main" => {
@@ -242,52 +279,74 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("退出应用");
app.exit(0);
}
id if id.starts_with("claude_") => {
let Some(provider_id) = id.strip_prefix("claude_") else {
log::error!("无效的 Claude 菜单项 ID: {}", id);
return;
};
log::info!("切换到Claude供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
) {
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
let Some(provider_id) = id.strip_prefix("codex_") else {
log::error!("无效的 Codex 菜单项 ID: {}", id);
return;
};
log::info!("切换到Codex供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
) {
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
log::warn!("未处理的菜单事件: {}", event_id);
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
/// - 可选:在成功时聚焦主窗口
fn handle_deeplink_url(
app: &tauri::AppHandle,
url_str: &str,
focus_main_window: bool,
source: &str,
) -> bool {
if !url_str.starts_with("ccswitch://") {
return false;
}
log::info!("✓ Deep link URL detected from {source}: {url_str}");
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
log::info!(
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
request.resource,
request.app,
request.name
);
if let Err(e) = app.emit("deeplink-import", &request) {
log::error!("✗ Failed to emit deeplink-import event: {e}");
} else {
log::info!("✓ Emitted deeplink-import event to frontend");
}
if focus_main_window {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
log::info!("✓ Window shown and focused");
}
}
}
Err(e) => {
log::error!("✗ Failed to parse deep link URL: {e}");
if let Err(emit_err) = app.emit(
"deeplink-error",
serde_json::json!({
"url": url_str,
"error": e.to_string()
}),
) {
log::error!("✗ Failed to emit deeplink-error event: {emit_err}");
}
}
}
true
}
//
/// 内部切换供应商函数
@@ -308,7 +367,7 @@ fn switch_provider_internal(
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {}", e);
log::error!("更新托盘菜单失败: {e}");
}
}
}
@@ -319,7 +378,7 @@ fn switch_provider_internal(
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {}", e);
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
@@ -335,13 +394,13 @@ async fn update_tray_menu(
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
return Ok(true);
}
Ok(false)
}
Err(err) => {
log::error!("创建托盘菜单失败: {}", err);
log::error!("创建托盘菜单失败: {err}");
Ok(false)
}
}
@@ -353,7 +412,27 @@ pub fn run() {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
log::info!("=== Single Instance Callback Triggered ===");
log::info!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::info!(" arg[{i}]: {arg}");
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false;
for arg in &args {
if handle_deeplink_url(app, arg, false, "single_instance args") {
found_deeplink = true;
break;
}
}
if !found_deeplink {
log::info!(" No deep link URL found in args (this is expected on macOS when launched via system)");
}
// Show and focus window regardless
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
@@ -363,6 +442,8 @@ pub fn run() {
}
let builder = builder
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
.plugin(tauri_plugin_deep_link::init())
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
@@ -397,7 +478,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
#[cfg(target_os = "macos")]
@@ -454,7 +535,7 @@ pub fn run() {
});
// 事件通知(可能早于前端订阅,不保证送达)
if let Err(e) = app.emit("configLoadError", payload_json) {
log::error!("发射配置加载错误事件失败: {}", e);
log::error!("发射配置加载错误事件失败: {e}");
}
// 同时缓存错误,供前端启动阶段主动拉取
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
@@ -468,7 +549,7 @@ pub fn run() {
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {}", e);
log::warn!("迁移 app_config_dir 失败: {e}");
}
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
@@ -478,7 +559,40 @@ pub fn run() {
config_guard.ensure_app(&app_config::AppType::Codex);
}
// 启动阶段不再无条件保存避免意外覆盖用户配置。
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API
log::info!("=== Registering deep-link URL handler ===");
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
}
}
// 注册 URL 处理回调(所有平台通用)
app.deep_link().on_open_url({
let app_handle = app.handle().clone();
move |event| {
log::info!("=== Deep Link Event Received (on_open_url) ===");
let urls = event.urls();
log::info!("Received {} URL(s)", urls.len());
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::info!(" URL[{i}]: {url_str}");
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
break; // Process only first ccswitch:// URL
}
}
}
});
log::info!("✓ Deep-link URL handler registered");
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
@@ -502,6 +616,17 @@ pub fn run() {
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -522,6 +647,10 @@ pub fn run() {
commands::get_init_error,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
@@ -546,10 +675,18 @@ pub fn run() {
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::import_mcp_from_claude,
commands::import_mcp_from_codex,
// v3.7.0: Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
commands::delete_prompt,
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
@@ -567,7 +704,25 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
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
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
commands::get_skills,
commands::install_skill,
commands::uninstall_skill,
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
@@ -576,17 +731,74 @@ pub fn run() {
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
if let RunEvent::Reopen { .. } = event {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
{
match event {
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
}
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
RunEvent::Opened { urls } => {
if let Some(url) = urls.first() {
let url_str = url.to_string();
log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") {
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
log::info!(
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={}",
request.resource,
request.app
);
if let Err(e) =
app_handle.emit("deeplink-import", &request)
{
log::error!(
"Failed to emit deep link event from RunEvent::Opened: {e}"
);
}
}
Err(e) => {
log::error!(
"Failed to parse deep link URL from RunEvent::Opened: {e}"
);
if let Err(emit_err) = app_handle.emit(
"deeplink-error",
serde_json::json!({
"url": url_str,
"error": e.to_string()
}),
) {
log::error!(
"Failed to emit deep link error event from RunEvent::Opened: {emit_err}"
);
}
}
}
// 确保主窗口可见
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
}
}
_ => {}
}
}

File diff suppressed because it is too large Load Diff

16
src-tauri/src/prompt.rs Normal file
View File

@@ -0,0 +1,16 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prompt {
pub id: String,
pub name: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}

View File

@@ -0,0 +1,41 @@
use std::path::PathBuf;
use crate::app_config::AppType;
use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
let base_dir: PathBuf = match app {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
};
Ok(base_dir.join(filename))
}
fn get_base_dir_with_fallback(
primary_path: PathBuf,
fallback_dir: &str,
) -> Result<PathBuf, AppError> {
primary_path
.parent()
.map(|p| p.to_path_buf())
.or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))
.ok_or_else(|| {
AppError::localized(
"home_dir_not_found",
format!("无法确定 {fallback_dir} 配置目录:用户主目录不存在"),
format!("Cannot determine {fallback_dir} config directory: user home not found"),
)
})
}

View File

@@ -22,9 +22,19 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 供应商元数据(不写入 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 {
@@ -43,7 +53,10 @@ impl Provider {
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
}
}
}
@@ -128,6 +141,15 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 合作伙伴标记(前端使用 isPartner保持字段名一致
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
/// 合作伙伴促销 key用于识别 PackyCode 等特殊供应商
#[serde(
rename = "partnerPromotionKey",
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
}
impl ProviderManager {

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

@@ -1,3 +1,4 @@
use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
@@ -20,7 +21,7 @@ impl ConfigService {
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{}", timestamp);
let backup_id = format!("backup_{timestamp}");
let backup_dir = config_path
.parent()
@@ -29,7 +30,7 @@ impl ConfigService {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_path = backup_dir.join(format!("{}.json", backup_id));
let backup_path = backup_dir.join(format!("{backup_id}.json"));
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
@@ -123,6 +124,7 @@ impl ConfigService {
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Ok(())
}
@@ -145,9 +147,7 @@ impl ConfigService {
Some(provider) => provider.clone(),
None => {
log::warn!(
"当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步",
app_type,
current_id
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
);
return Ok(());
}
@@ -158,6 +158,7 @@ impl ConfigService {
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
}
Ok(())
@@ -169,18 +170,14 @@ impl ConfigService {
provider: &Provider,
) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id))
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置缺少 auth 字段",
provider_id
))
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
provider_id
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
@@ -226,4 +223,35 @@ impl ConfigService {
Ok(())
}
fn sync_gemini_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, read_gemini_env};
ProviderService::write_gemini_live(provider)?;
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let settings_path = crate::gemini_config::get_gemini_settings_path();
let live_after_config = if settings_path.exists() {
crate::config::read_json_file(&settings_path)?
} else {
serde_json::json!({})
};
let mut live_after = env_to_json(&live_after_env);
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), live_after_config);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,168 @@
use serde::{Deserialize, Serialize};
#[cfg(not(target_os = "windows"))]
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvConflict {
pub var_name: String,
pub var_value: String,
pub source_type: String, // "system" | "file"
pub source_path: String, // Registry path or file path
}
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
/// Check environment variables for conflicts
pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
let keywords = get_keywords_for_app(app);
let mut conflicts = Vec::new();
// Check system environment variables
conflicts.extend(check_system_env(&keywords)?);
// Check shell configuration files (Unix only)
#[cfg(not(target_os = "windows"))]
conflicts.extend(check_shell_configs(&keywords)?);
Ok(conflicts)
}
/// Get relevant keywords for each app
fn get_keywords_for_app(app: &str) -> Vec<&str> {
match app.to_lowercase().as_str() {
"claude" => vec!["ANTHROPIC"],
"codex" => vec!["OPENAI"],
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
_ => vec![],
}
}
/// Check system environment variables (Windows Registry or Unix env)
#[cfg(target_os = "windows")]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check HKEY_CURRENT_USER\Environment
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
source_type: "system".to_string(),
source_path: "HKEY_CURRENT_USER\\Environment".to_string(),
});
}
}
}
// Check HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
source_type: "system".to_string(),
source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(),
});
}
}
}
Ok(conflicts)
}
#[cfg(not(target_os = "windows"))]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check current process environment
for (key, value) in std::env::vars() {
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: key,
var_value: value,
source_type: "system".to_string(),
source_path: "Process Environment".to_string(),
});
}
}
Ok(conflicts)
}
/// Check shell configuration files for environment variable exports (Unix only)
#[cfg(not(target_os = "windows"))]
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let config_files = vec![
format!("{}/.bashrc", home),
format!("{}/.bash_profile", home),
format!("{}/.zshrc", home),
format!("{}/.zprofile", home),
format!("{}/.profile", home),
"/etc/profile".to_string(),
"/etc/bashrc".to_string(),
];
for file_path in config_files {
if let Ok(content) = fs::read_to_string(&file_path) {
// Parse lines for export statements
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Match patterns like: export VAR=value or VAR=value
if trimmed.starts_with("export ")
|| (!trimmed.starts_with('#') && trimmed.contains('='))
{
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
if let Some(eq_pos) = export_line.find('=') {
let var_name = export_line[..eq_pos].trim();
let var_value = export_line[eq_pos + 1..].trim();
// Check if variable name contains any keyword
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: var_name.to_string(),
var_value: var_value
.trim_matches('"')
.trim_matches('\'')
.to_string(),
source_type: "file".to_string(),
source_path: format!("{}:{}", file_path, line_num + 1),
});
}
}
}
}
}
}
Ok(conflicts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_keywords() {
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
assert_eq!(
get_keywords_for_app("gemini"),
vec!["GEMINI", "GOOGLE_GEMINI"]
);
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
}
}

View File

@@ -0,0 +1,240 @@
use super::env_checker::EnvConflict;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupInfo {
pub backup_path: String,
pub timestamp: String,
pub conflicts: Vec<EnvConflict>,
}
/// Delete environment variables with automatic backup
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
// Step 1: Create backup
let backup_info = create_backup(&conflicts)?;
// Step 2: Delete variables
for conflict in &conflicts {
match delete_single_env(conflict) {
Ok(_) => {}
Err(e) => {
// If deletion fails, we keep the backup but return error
return Err(format!(
"删除环境变量失败: {}. 备份已保存到: {}",
e, backup_info.backup_path
));
}
}
}
Ok(backup_info)
}
/// Create backup file before deletion
fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
// Get backup directory
let backup_dir = get_backup_dir()?;
fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
// Generate backup file name with timestamp
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
// Create backup data
let backup_info = BackupInfo {
backup_path: backup_file.to_string_lossy().to_string(),
timestamp: timestamp.clone(),
conflicts: conflicts.to_vec(),
};
// Write backup file
let json = serde_json::to_string_pretty(&backup_info)
.map_err(|e| format!("序列化备份数据失败: {e}"))?;
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
Ok(backup_info)
}
/// Get backup directory path
fn get_backup_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("无法获取用户主目录")?;
Ok(home.join(".cc-switch").join("backups"))
}
/// Delete a single environment variable
#[cfg(target_os = "windows")]
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"system" => {
if conflict.source_path.contains("HKEY_CURRENT_USER") {
let hkcu = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags("Environment", KEY_ALL_ACCESS)
.map_err(|e| format!("打开注册表失败: {}", e))?;
hkcu.delete_value(&conflict.var_name)
.map_err(|e| format!("删除注册表项失败: {}", e))?;
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey_with_flags(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
KEY_ALL_ACCESS,
)
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
hklm.delete_value(&conflict.var_name)
.map_err(|e| format!("删除系统注册表项失败: {}", e))?;
}
Ok(())
}
"file" => Err("Windows 系统不应该有文件类型的环境变量".to_string()),
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
}
}
#[cfg(not(target_os = "windows"))]
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"file" => {
// Parse file path and line number from source_path (format: "path:line")
let parts: Vec<&str> = conflict.source_path.split(':').collect();
if parts.len() < 2 {
return Err("无效的文件路径格式".to_string());
}
let file_path = parts[0];
// Read file content
let content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Filter out the line containing the environment variable
let new_content: Vec<String> = content
.lines()
.filter(|line| {
let trimmed = line.trim();
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
// Check if this line sets the target variable
if let Some(eq_pos) = export_line.find('=') {
let var_name = export_line[..eq_pos].trim();
var_name != conflict.var_name
} else {
true
}
})
.map(|s| s.to_string())
.collect();
// Write back to file
fs::write(file_path, new_content.join("\n"))
.map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
"system" => {
// On Unix, we can't directly delete process environment variables
Ok(())
}
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
}
}
/// Restore environment variables from backup
pub fn restore_from_backup(backup_path: String) -> Result<(), String> {
// Read backup file
let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?;
let backup_info: BackupInfo =
serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
// Restore each variable
for conflict in &backup_info.conflicts {
restore_single_env(conflict)?;
}
Ok(())
}
/// Restore a single environment variable
#[cfg(target_os = "windows")]
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"system" => {
if conflict.source_path.contains("HKEY_CURRENT_USER") {
let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER)
.create_subkey("Environment")
.map_err(|e| format!("打开注册表失败: {}", e))?;
hkcu.set_value(&conflict.var_name, &conflict.var_value)
.map_err(|e| format!("恢复注册表项失败: {}", e))?;
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE)
.create_subkey(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
)
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
hklm.set_value(&conflict.var_name, &conflict.var_value)
.map_err(|e| format!("恢复系统注册表项失败: {}", e))?;
}
Ok(())
}
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}
#[cfg(not(target_os = "windows"))]
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"file" => {
// Parse file path from source_path
let parts: Vec<&str> = conflict.source_path.split(':').collect();
if parts.is_empty() {
return Err("无效的文件路径格式".to_string());
}
let file_path = parts[0];
// Read file content
let mut content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Append the environment variable line
let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value);
content.push_str(&export_line);
// Write back to file
fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backup_dir_creation() {
let backup_dir = get_backup_dir();
assert!(backup_dir.is_ok());
}
}

View File

@@ -1,191 +1,260 @@
use std::collections::HashMap;
use serde_json::Value;
use crate::app_config::{AppType, MultiAppConfig};
use crate::app_config::{AppType, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::mcp;
use crate::store::AppState;
/// MCP 相关业务逻辑
/// MCP 相关业务逻辑v3.7.0 统一结构)
pub struct McpService;
impl McpService {
/// 获取指定应用的 MCP 服务器快照,并在必要时回写归一化后的配置。
pub fn get_servers(state: &AppState, app: AppType) -> Result<HashMap<String, Value>, AppError> {
let mut cfg = state.config.write()?;
let (snapshot, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app);
drop(cfg);
if normalized > 0 {
state.save()?;
/// 获取所有 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());
}
Ok(snapshot)
// 理论上不应该走到这里,因为 load 时会自动迁移
Err(AppError::localized(
"mcp.old_structure",
"检测到旧版 MCP 结构,请重启应用完成迁移",
"Old MCP structure detected, please restart app to complete migration",
))
}
/// 在 config.json 中新增或更新指定 MCP 服务器,并按需同步到对应客户端。
pub fn upsert_server(
state: &AppState,
app: AppType,
id: &str,
spec: Value,
sync_other_side: bool,
) -> Result<bool, AppError> {
let (changed, snapshot, sync_claude, sync_codex): (
bool,
Option<MultiAppConfig>,
bool,
bool,
) = {
/// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
{
let mut cfg = state.config.write()?;
let changed = mcp::upsert_in_config_for(&mut cfg, &app, id, spec)?;
// 修复默认启用unwrap_or(true)
// 新增的 MCP 如果缺少 enabled 字段,应该默认为启用状态
let enabled = cfg
.mcp_for(&app)
.servers
.get(id)
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut sync_claude = matches!(app, AppType::Claude) && enabled;
let mut sync_codex = matches!(app, AppType::Codex) && enabled;
// 修复sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步
// 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制
if sync_other_side {
// 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败)
let current_entry = cfg
.mcp_for(&app)
.servers
.get(id)
.cloned()
.expect("刚刚插入的 MCP 条目必定存在");
// 将该 MCP 复制到另一侧的 servers
let other_app = match app {
AppType::Claude => AppType::Codex,
AppType::Codex => AppType::Claude,
};
cfg.mcp_for_mut(&other_app)
.servers
.insert(id.to_string(), current_entry);
// 强制同步另一侧
match app {
AppType::Claude => sync_codex = true,
AppType::Codex => sync_claude = true,
}
// 确保 servers 字段存在
if cfg.mcp.servers.is_none() {
cfg.mcp.servers = Some(HashMap::new());
}
let snapshot = if sync_claude || sync_codex {
Some(cfg.clone())
} else {
None
};
let servers = cfg.mcp.servers.as_mut().unwrap();
let id = server.id.clone();
(changed, snapshot, sync_claude, sync_codex)
};
// 插入或更新
servers.insert(id, server.clone());
}
// 保持原有行为:始终尝试持久化,避免遗漏 normalize 带来的隐式变更
state.save()?;
if let Some(snapshot) = snapshot {
if sync_claude {
mcp::sync_enabled_to_claude(&snapshot)?;
}
if sync_codex {
mcp::sync_enabled_to_codex(&snapshot)?;
}
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
Ok(changed)
Ok(())
}
/// 删除 config.json 中的 MCP 服务器条目,并同步客户端配置。
pub fn delete_server(state: &AppState, app: AppType, id: &str) -> Result<bool, AppError> {
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
/// 删除 MCP 服务器
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
let server = {
let mut cfg = state.config.write()?;
let existed = mcp::delete_in_config_for(&mut cfg, &app, id)?;
let snapshot = if existed { Some(cfg.clone()) } else { None };
(existed, snapshot)
};
if existed {
state.save()?;
if let Some(snapshot) = snapshot {
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
}
if let Some(servers) = &mut cfg.mcp.servers {
servers.remove(id)
} else {
None
}
};
if let Some(server) = server {
state.save()?;
// 从所有应用的 live 配置中移除
Self::remove_server_from_all_apps(state, id, &server)?;
Ok(true)
} else {
Ok(false)
}
Ok(existed)
}
/// 设置 MCP 启用状态,并同步到客户端配置。
/// 切换指定应用的启用状态
pub fn toggle_app(
state: &AppState,
server_id: &str,
app: AppType,
enabled: bool,
) -> Result<(), AppError> {
let server = {
let mut cfg = state.config.write()?;
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 enabled {
Self::sync_server_to_app(state, &server, &app)?;
} else {
Self::remove_server_from_app(state, server_id, &app)?;
}
}
Ok(())
}
/// 将 MCP 服务器同步到所有启用的应用
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
let cfg = state.config.read()?;
for app in server.apps.enabled_apps() {
Self::sync_server_to_app_internal(&cfg, server, &app)?;
}
Ok(())
}
/// 将 MCP 服务器同步到指定应用
fn sync_server_to_app(
state: &AppState,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
let cfg = state.config.read()?;
Self::sync_server_to_app_internal(&cfg, server, app)
}
fn sync_server_to_app_internal(
cfg: &MultiAppConfig,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
match app {
AppType::Claude => {
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
}
AppType::Codex => {
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
}
AppType::Gemini => {
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
}
}
Ok(())
}
/// 从所有曾启用过该服务器的应用中移除
fn remove_server_from_all_apps(
state: &AppState,
id: &str,
server: &McpServer,
) -> Result<(), AppError> {
// 从所有曾启用的应用中移除
for app in server.apps.enabled_apps() {
Self::remove_server_from_app(state, id, &app)?;
}
Ok(())
}
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
}
Ok(())
}
/// 手动同步所有启用的 MCP 服务器到对应的应用
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
Self::sync_server_to_apps(state, server)?;
}
Ok(())
}
// ========================================================================
// 兼容层:支持旧的 v3.6.x 命令(已废弃,将在 v4.0 移除)
// ========================================================================
/// [已废弃] 获取指定应用的 MCP 服务器(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use get_all_servers instead")]
pub fn get_servers(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, serde_json::Value>, AppError> {
let all_servers = Self::get_all_servers(state)?;
let mut result = HashMap::new();
for (id, server) in all_servers {
if server.apps.is_enabled_for(&app) {
result.insert(id, server.server);
}
}
Ok(result)
}
/// [已废弃] 设置 MCP 服务器在指定应用的启用状态(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use toggle_app instead")]
pub fn set_enabled(
state: &AppState,
app: AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
let mut cfg = state.config.write()?;
let existed = mcp::set_enabled_flag_for(&mut cfg, &app, id, enabled)?;
let snapshot = if existed { Some(cfg.clone()) } else { None };
(existed, snapshot)
};
if existed {
state.save()?;
if let Some(snapshot) = snapshot {
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
}
}
}
Ok(existed)
Self::toggle_app(state, id, app, enabled)?;
Ok(true)
}
/// 手动同步启用的 MCP 服务器到客户端配置。
/// [已废弃] 同步启用的 MCP 到指定应用(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use sync_all_enabled instead")]
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
let (snapshot, normalized): (MultiAppConfig, usize) = {
let mut cfg = state.config.write()?;
let normalized = mcp::normalize_servers_for(&mut cfg, &app);
(cfg.clone(), normalized)
};
if normalized > 0 {
state.save()?;
}
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
if server.apps.is_enabled_for(&app) {
Self::sync_server_to_app(state, server, &app)?;
}
}
Ok(())
}
/// 从 Claude 客户端配置导入 MCP 定义。
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let changed = mcp::import_from_claude(&mut cfg)?;
let count = mcp::import_from_claude(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
state.save()?;
Ok(count)
}
/// 从 Codex 客户端配置导入 MCP 定义。
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let changed = mcp::import_from_codex(&mut cfg)?;
let count = mcp::import_from_codex(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
state.save()?;
Ok(count)
}
/// 从 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)
}
}

View File

@@ -1,9 +1,15 @@
pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod prompt;
pub mod provider;
pub mod skill;
pub mod speedtest;
pub use config::ConfigService;
pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use skill::{Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};

View File

@@ -0,0 +1,203 @@
use std::collections::HashMap;
use crate::app_config::AppType;
use crate::config::write_text_file;
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::prompt_files::prompt_file_path;
use crate::store::AppState;
pub struct PromptService;
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())
}
pub fn upsert_prompt(
state: &AppState,
app: AppType,
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()?;
// 如果是已启用的提示词,同步更新到对应的文件
if is_enabled {
let target_path = prompt_file_path(&app)?;
write_text_file(&target_path, &prompt.content)?;
}
Ok(())
}
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,
};
if let Some(prompt) = prompts.get(id) {
if prompt.enabled {
return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
}
}
prompts.remove(id);
drop(cfg);
state.save()?;
Ok(())
}
pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
// 回填当前 live 文件内容到已启用的提示词,或创建备份
let target_path = prompt_file_path(&app)?;
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,
};
// 尝试回填到当前已启用的提示词
if let Some((enabled_id, enabled_prompt)) = prompts
.iter_mut()
.find(|(_, p)| p.enabled)
.map(|(id, p)| (id.clone(), p))
{
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
enabled_prompt.content = live_content.clone();
enabled_prompt.updated_at = Some(timestamp);
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
drop(cfg); // 释放锁后保存,避免死锁
state.save()?; // 第一次保存:回填后立即持久化
} else {
// 没有已启用的提示词,则创建一次备份(避免重复备份)
let content_exists = prompts
.values()
.any(|p| p.content.trim() == live_content.trim());
if !content_exists {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt {
id: backup_id.clone(),
name: format!(
"原始提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content: live_content,
description: Some("自动备份的原始提示词".to_string()),
enabled: false,
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);
}
}
}
}
}
// 启用目标提示词并写入文件
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,
};
for prompt in prompts.values_mut() {
prompt.enabled = false;
}
if let Some(prompt) = prompts.get_mut(id) {
prompt.enabled = true;
write_text_file(&target_path, &prompt.content)?; // 原子写入
} else {
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
}
drop(cfg);
state.save()?; // 第二次保存:启用目标提示词并写入文件后
Ok(())
}
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Err(AppError::Message("提示词文件不存在".to_string()));
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let id = format!("imported-{timestamp}");
let prompt = Prompt {
id: id.clone(),
name: format!(
"导入的提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("从现有配置文件导入".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
Self::upsert_prompt(state, app, &id, prompt)?;
Ok(id)
}
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
Ok(Some(content))
}
}

View File

@@ -11,7 +11,6 @@ use crate::config::{
write_json_file, write_text_file,
};
use crate::error::AppError;
use crate::mcp;
use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult};
use crate::settings::{self, CustomEndpoint};
use crate::store::AppState;
@@ -29,6 +28,10 @@ enum LiveSnapshot {
auth: Option<Value>,
config: Option<String>,
},
Gemini {
env: Option<HashMap<String, String>>, // 新增
config: Option<Value>, // 新增settings.json 内容
},
}
#[derive(Clone)]
@@ -66,6 +69,31 @@ impl LiveSnapshot {
delete_file(&config_path)?;
}
}
LiveSnapshot::Gemini { env, .. } => {
// 新增
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,
};
let path = get_gemini_env_path();
if let Some(env_map) = env {
write_gemini_env_atomic(env_map)?;
} else if path.exists() {
delete_file(&path)?;
}
let settings_path = get_gemini_settings_path();
match self {
LiveSnapshot::Gemini {
config: Some(cfg), ..
} => {
write_json_file(&settings_path, cfg)?;
}
LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {
delete_file(&settings_path)?;
}
_ => {}
}
}
}
Ok(())
}
@@ -111,7 +139,285 @@ mod tests {
}
}
/// Gemini 认证类型枚举
///
/// 用于优化性能,避免重复检测供应商类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GeminiAuthType {
/// PackyCode 供应商(使用 API Key
Packycode,
/// Google 官方(使用 OAuth
GoogleOfficial,
/// 通用 Gemini 供应商(使用 API Key
Generic,
}
impl ProviderService {
// 认证类型常量
const PACKYCODE_SECURITY_SELECTED_TYPE: &'static str = "gemini-api-key";
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &'static str = "oauth-personal";
// Partner Promotion Key 常量
const PACKYCODE_PARTNER_KEY: &'static str = "packycode";
const GOOGLE_OFFICIAL_PARTNER_KEY: &'static str = "google-official";
// PackyCode 关键词常量
const PACKYCODE_KEYWORDS: [&'static str; 3] = ["packycode", "packyapi", "packy"];
/// 检测 Gemini 供应商的认证类型
///
/// 一次性检测,避免在多个地方重复调用 `is_packycode_gemini` 和 `is_google_official_gemini`
///
/// # 返回值
///
/// - `GeminiAuthType::GoogleOfficial`: Google 官方,使用 OAuth
/// - `GeminiAuthType::Packycode`: PackyCode 供应商,使用 API Key
/// - `GeminiAuthType::Generic`: 其他通用供应商,使用 API Key
fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType {
// 优先检查 partner_promotion_key最可靠
if let Some(key) = provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
{
if key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY) {
return GeminiAuthType::GoogleOfficial;
}
if key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY) {
return GeminiAuthType::Packycode;
}
}
// 检查 Google 官方(名称匹配)
let name_lower = provider.name.to_ascii_lowercase();
if name_lower == "google" || name_lower.starts_with("google ") {
return GeminiAuthType::GoogleOfficial;
}
// 检查 PackyCode 关键词
if Self::contains_packycode_keyword(&provider.name) {
return GeminiAuthType::Packycode;
}
if let Some(site) = provider.website_url.as_deref() {
if Self::contains_packycode_keyword(site) {
return GeminiAuthType::Packycode;
}
}
if let Some(base_url) = provider
.settings_config
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
{
if Self::contains_packycode_keyword(base_url) {
return GeminiAuthType::Packycode;
}
}
GeminiAuthType::Generic
}
/// 检查字符串是否包含 PackyCode 相关关键词(不区分大小写)
///
/// 关键词列表:["packycode", "packyapi", "packy"]
fn contains_packycode_keyword(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
Self::PACKYCODE_KEYWORDS
.iter()
.any(|keyword| lower.contains(keyword))
}
/// 检测供应商是否为 PackyCode Gemini使用 API Key 认证)
///
/// PackyCode 是官方合作伙伴,需要特殊的安全配置。
///
/// # 检测规则(优先级从高到低)
///
/// 1. **Partner Promotion Key**(最可靠):
/// - `provider.meta.partner_promotion_key == "packycode"`
///
/// 2. **供应商名称**:
/// - 名称包含 "packycode"、"packyapi" 或 "packy"(不区分大小写)
///
/// 3. **网站 URL**:
/// - `provider.website_url` 包含关键词
///
/// 4. **Base URL**:
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` 包含关键词
///
/// # 为什么需要多重检测
///
/// - 用户可能手动创建供应商,没有 `partner_promotion_key`
/// - 从预设复制后可能修改了 meta 字段
/// - 确保所有 PackyCode 供应商都能正确设置安全标志
fn is_packycode_gemini(provider: &Provider) -> bool {
// 策略 1: 检查 partner_promotion_key最可靠
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY))
{
return true;
}
// 策略 2: 检查供应商名称
if Self::contains_packycode_keyword(&provider.name) {
return true;
}
// 策略 3: 检查网站 URL
if let Some(site) = provider.website_url.as_deref() {
if Self::contains_packycode_keyword(site) {
return true;
}
}
// 策略 4: 检查 Base URL
if let Some(base_url) = provider
.settings_config
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
{
if Self::contains_packycode_keyword(base_url) {
return true;
}
}
false
}
/// 检测供应商是否为 Google 官方 Gemini使用 OAuth 认证)
///
/// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。
///
/// # 检测规则(优先级从高到低)
///
/// 1. **Partner Promotion Key**(最可靠):
/// - `provider.meta.partner_promotion_key == "google-official"`
///
/// 2. **供应商名称**:
/// - 名称完全等于 "google"(不区分大小写)
/// - 或名称以 "google " 开头(例如 "Google Official"
///
/// # OAuth vs API Key
///
/// - **OAuth 模式**: `security.auth.selectedType = "oauth-personal"`
/// - 用户需要通过浏览器登录 Google 账号
/// - 不需要在 `.env` 文件中配置 API Key
///
/// - **API Key 模式**: `security.auth.selectedType = "gemini-api-key"`
/// - 用于第三方中转服务(如 PackyCode
/// - 需要在 `.env` 文件中配置 `GEMINI_API_KEY`
fn is_google_official_gemini(provider: &Provider) -> bool {
// 策略 1: 检查 partner_promotion_key最可靠
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY))
{
return true;
}
// 策略 2: 检查名称匹配(备用方案)
let name_lower = provider.name.to_ascii_lowercase();
name_lower == "google" || name_lower.starts_with("google ")
}
/// 确保 PackyCode Gemini 供应商的安全标志正确设置
///
/// PackyCode 是官方合作伙伴,使用 API Key 认证模式。
///
/// # 写入两处 settings.json 的原因
///
/// 1. **`~/.cc-switch/settings.json`** (应用级配置):
/// - CC-Switch 应用的全局设置
/// - 确保应用知道当前使用的认证类型
/// - 用于 UI 显示和其他应用逻辑
///
/// 2. **`~/.gemini/settings.json`** (Gemini 客户端配置):
/// - Gemini CLI 客户端读取的配置文件
/// - 直接影响 Gemini 客户端的认证行为
/// - 确保 Gemini 使用正确的认证方式连接 API
///
/// # 设置的值
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// # 错误处理
///
/// 如果供应商不是 PackyCode函数立即返回 `Ok(())`,不做任何操作。
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
if !Self::is_packycode_gemini(provider) {
return Ok(());
}
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(Self::PACKYCODE_SECURITY_SELECTED_TYPE)?;
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_packycode_settings;
write_packycode_settings()?;
Ok(())
}
/// 确保 Google 官方 Gemini 供应商的安全标志正确设置OAuth 模式)
///
/// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。
///
/// # 写入两处 settings.json 的原因
///
/// 同 `ensure_packycode_security_flag`,需要同时配置应用级和客户端级设置。
///
/// # 设置的值
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "oauth-personal"
/// }
/// }
/// }
/// ```
///
/// # OAuth 认证流程
///
/// 1. 用户切换到 Google 官方供应商
/// 2. CC-Switch 设置 `selectedType = "oauth-personal"`
/// 3. 用户首次使用 Gemini CLI 时,会自动打开浏览器进行 OAuth 登录
/// 4. 登录成功后,凭证保存在 Gemini 的 credential store 中
/// 5. 后续请求自动使用保存的凭证
///
/// # 错误处理
///
/// 如果供应商不是 Google 官方,函数立即返回 `Ok(())`,不做任何操作。
pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> {
if !Self::is_google_official_gemini(provider) {
return Ok(());
}
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(Self::GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_google_oauth_settings;
write_google_oauth_settings()?;
Ok(())
}
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
let mut changed = false;
@@ -211,11 +517,8 @@ impl ProviderService {
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: {}; rollback failed: {}",
save_err, rollback_err
),
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"),
));
}
return Err(save_err);
@@ -228,11 +531,8 @@ impl ProviderService {
{
return Err(AppError::localized(
"post_commit.rollback_failed",
format!("后置操作失败: {};回滚失败: {}", err, rollback_err),
format!(
"Post-commit step failed: {}; rollback failed: {}",
err, rollback_err
),
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"),
));
}
return Err(err);
@@ -262,11 +562,9 @@ impl ProviderService {
fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> {
Self::write_live_snapshot(&action.app_type, &action.provider)?;
if action.sync_mcp {
let config_clone = {
let guard = state.config.read().map_err(AppError::from)?;
guard.clone()
};
mcp::sync_enabled_to_codex(&config_clone)?;
// 使用 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)?;
@@ -319,8 +617,7 @@ impl ProviderService {
if let Some(target) = manager.providers.get_mut(provider_id) {
let obj = target.settings_config.as_object_mut().ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置必须是 JSON 对象",
provider_id
"供应商 {provider_id} 的 Codex 配置必须是 JSON 对象"
))
})?;
obj.insert("auth".to_string(), auth.clone());
@@ -330,6 +627,43 @@ impl ProviderService {
}
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(())
}
@@ -363,6 +697,25 @@ impl ProviderService {
};
Ok(LiveSnapshot::Codex { auth, config })
}
AppType::Gemini => {
// 新增
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
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 })
}
}
}
@@ -447,8 +800,8 @@ impl ProviderService {
if !manager.providers.contains_key(&provider_id) {
return Err(AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
));
}
@@ -530,6 +883,39 @@ impl ProviderService {
let _ = Self::normalize_claude_models_in_value(&mut v);
v
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// 读取 .env 文件(环境变量)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.live.missing",
"Gemini 配置文件不存在",
"Gemini configuration file is missing",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// 读取 settings.json 文件MCP 配置等)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// 返回完整结构:{ "env": {...}, "config": {...} }
json!({
"env": env_obj,
"config": config_obj
})
}
};
let mut provider = Provider::with_id(
@@ -582,6 +968,39 @@ impl ProviderService {
}
read_json_file(&path)
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// 读取 .env 文件(环境变量)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.env.missing",
"Gemini .env 文件不存在",
"Gemini .env file not found",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// 读取 settings.json 文件MCP 配置等)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// 返回完整结构:{ "env": {...}, "config": {...} }
Ok(json!({
"env": env_obj,
"config": config_obj
}))
}
}
}
@@ -635,8 +1054,8 @@ impl ProviderService {
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),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
@@ -750,16 +1169,16 @@ impl ProviderService {
serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {}", e),
format!("Data format error: {}", e),
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?
} else {
let single: UsageData = serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {}", e),
format!("Data format error: {}", e),
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?;
vec![single]
@@ -810,8 +1229,8 @@ impl ProviderService {
let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
@@ -891,13 +1310,14 @@ impl ProviderService {
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: matches!(app_type_clone, AppType::Codex),
sync_mcp: true, // v3.7.0: 所有应用切换时都同步 MCP防止配置丢失
refresh_snapshot: true,
};
@@ -918,8 +1338,8 @@ impl ProviderService {
.ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
@@ -1005,8 +1425,8 @@ impl ProviderService {
.ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
@@ -1019,6 +1439,33 @@ impl ProviderService {
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,
@@ -1047,23 +1494,130 @@ impl ProviderService {
Ok(())
}
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
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(())
}
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
let mut content = provider.settings_config.clone();
let _ = Self::normalize_claude_models_in_value(&mut content);
write_json_file(&settings_path, &content)?;
Ok(())
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,
write_gemini_env_atomic,
};
// 一次性检测认证类型,避免重复检测
let auth_type = Self::detect_gemini_auth_type(provider);
let mut env_map = json_to_env(&provider.settings_config)?;
// 准备要写入 ~/.gemini/settings.json 的配置(缺省时保留现有文件内容)
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config")
{
if config_value.is_null() {
Some(json!({}))
} else if config_value.is_object() {
Some(config_value.clone())
} else {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
} else {
None
};
if config_to_write.is_none() {
let settings_path = get_gemini_settings_path();
if settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
}
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google 官方使用 OAuth清空 env
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode => {
// PackyCode 供应商,使用 API Key切换时严格验证
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Generic => {
// 通用供应商,使用 API Key切换时严格验证
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
if let Some(config_value) = config_to_write {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, &config_value)?;
}
match auth_type {
GeminiAuthType::GoogleOfficial => Self::ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => Self::ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
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), // 新增
}
}
@@ -1118,6 +1672,38 @@ impl ProviderService {
}
}
}
AppType::Gemini => {
// 新增
use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)?
}
}
// 🔧 验证并清理 UsageScript 配置(所有应用类型通用)
if let Some(meta) = &provider.meta {
if let Some(usage_script) = &meta.usage_script {
Self::validate_usage_script(usage_script)?;
}
}
Ok(())
}
/// 验证 UsageScript 配置(边界检查)
fn validate_usage_script(script: &crate::provider::UsageScript) -> Result<(), AppError> {
// 验证自动查询间隔 (0-1440 分钟即最大24小时)
if let Some(interval) = script.auto_query_interval {
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!(
"自动查询间隔不能超过 1440 分钟24小时当前值: {interval}"
),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
));
}
}
Ok(())
@@ -1204,8 +1790,8 @@ impl ProviderService {
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
AppError::localized(
"provider.regex_init_failed",
format!("正则初始化失败: {}", e),
format!("Failed to initialize regex: {}", e),
format!("正则初始化失败: {e}"),
format!("Failed to initialize regex: {e}"),
)
})?;
re.captures(config_toml)
@@ -1226,6 +1812,27 @@ impl ProviderService {
));
};
Ok((api_key, base_url))
}
AppType::Gemini => {
// 新增
use crate::gemini_config::json_to_env;
let env_map = json_to_env(&provider.settings_config)?;
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
AppError::localized(
"gemini.missing_api_key",
"缺少 GEMINI_API_KEY",
"Missing GEMINI_API_KEY",
)
})?;
let base_url = env_map
.get("GOOGLE_GEMINI_BASE_URL")
.cloned()
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
Ok((api_key, base_url))
}
}
@@ -1234,8 +1841,8 @@ impl ProviderService {
fn app_not_found(app_type: &AppType) -> AppError {
AppError::localized(
"provider.app_not_found",
format!("应用类型不存在: {:?}", app_type),
format!("App type not found: {:?}", app_type),
format!("应用类型不存在: {app_type:?}"),
format!("App type not found: {app_type:?}"),
)
}
@@ -1264,8 +1871,8 @@ impl ProviderService {
manager.providers.get(provider_id).cloned().ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?
};
@@ -1285,6 +1892,9 @@ impl ProviderService {
delete_file(&by_name)?;
delete_file(&by_id)?;
}
AppType::Gemini => {
// Gemini 使用单一的 .env 文件,不需要删除单独的供应商配置文件
}
}
{

View File

@@ -0,0 +1,583 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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 {
/// 唯一标识: "owner/name:directory" 或 "local:directory"
pub key: String,
/// 显示名称 (从 SKILL.md 解析)
pub name: String,
/// 技能描述
pub description: String,
/// 目录名称 (安装路径的最后一段)
pub directory: String,
/// GitHub README URL
#[serde(rename = "readmeUrl")]
pub readme_url: Option<String>,
/// 是否已安装
pub installed: bool,
/// 仓库所有者
#[serde(rename = "repoOwner")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(rename = "repoName")]
pub repo_name: Option<String>,
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRepo {
/// GitHub 用户/组织名
pub owner: String,
/// 仓库名称
pub name: String,
/// 分支 (默认 "main")
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
/// 是否已安装
pub installed: bool,
/// 安装时间
#[serde(rename = "installedAt")]
pub installed_at: DateTime<Utc>,
}
/// 持久化存储结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillStore {
/// directory -> 安装状态
pub skills: HashMap<String, SkillState>,
/// 仓库列表
pub repos: Vec<SkillRepo>,
}
impl Default for SkillStore {
fn default() -> Self {
SkillStore {
skills: HashMap::new(),
repos: vec![
SkillRepo {
owner: "ComposioHQ".to_string(),
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
},
],
}
}
}
/// 技能元数据 (从 SKILL.md 解析)
#[derive(Debug, Clone, Deserialize)]
pub struct SkillMetadata {
pub name: Option<String>,
pub description: Option<String>,
}
pub struct SkillService {
http_client: Client,
install_dir: PathBuf,
}
impl SkillService {
pub fn new() -> Result<Self> {
let install_dir = Self::get_install_dir()?;
// 确保目录存在
fs::create_dir_all(&install_dir)?;
Ok(Self {
http_client: Client::builder()
.user_agent("cc-switch")
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
.timeout(std::time::Duration::from_secs(10))
.build()?,
install_dir,
})
}
fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
Ok(home.join(".claude").join("skills"))
}
}
// 核心方法实现
impl SkillService {
/// 列出所有技能
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
let mut skills = Vec::new();
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
let fetch_tasks = enabled_repos
.iter()
.map(|repo| self.fetch_repo_skills(repo));
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
match result {
Ok(repo_skills) => skills.extend(repo_skills),
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
}
}
// 合并本地技能
self.merge_local_skills(&mut skills)?;
// 去重并排序
Self::deduplicate_skills(&mut skills);
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(skills)
}
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skillsPath则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 遍历目标目录
for entry in fs::read_dir(&scan_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 构建 README URL考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(skills)
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
// 移除 BOM
let content = content.trim_start_matches('\u{feff}');
// 提取 YAML front matter
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok(SkillMetadata {
name: None,
description: None,
});
}
let front_matter = parts[1].trim();
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
name: None,
description: None,
});
Ok(meta)
}
/// 合并本地技能
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
if !self.install_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 更新已安装状态
let mut found = false;
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(&directory) {
skill.installed = true;
found = true;
break;
}
}
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
}
}
Ok(())
}
/// 去重技能列表
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
true
} else {
false
}
});
}
/// 下载仓库
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
// 尝试多个分支
let branches = if repo.branch.is_empty() {
vec!["main", "master"]
} else {
vec![repo.branch.as_str(), "main", "master"]
};
let mut last_error = None;
for branch in branches {
let url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo.owner, repo.name, branch
);
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => {
return Ok(temp_path);
}
Err(e) => {
last_error = Some(e);
continue;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
}
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
// 下载 ZIP
let response = self.http_client.get(url).send().await?;
if !response.status().is_success() {
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?;
// 解压
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
let root_name = if !archive.is_empty() {
let first_file = archive.by_index(0)?;
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
};
// 解压所有文件
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name();
// 跳过根目录,直接提取内容
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
stripped
} else {
continue;
};
if relative_path.is_empty() {
continue;
}
let outpath = dest.join(relative_path);
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(())
}
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 若目标目录已存在,则视为已安装,避免重复下载
if dest.exists() {
return Ok(());
}
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
let temp_dir = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.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)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow::anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
// 删除旧版本
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
// 递归复制
Self::copy_dir_recursive(&source, &dest)?;
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(())
}
/// 递归复制目录
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest_path = dest.join(entry.file_name());
if path.is_dir() {
Self::copy_dir_recursive(&path, &dest_path)?;
} else {
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
let dest = self.install_dir.join(&directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
Ok(())
}
/// 列出仓库
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
store.repos.clone()
}
/// 添加仓库
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
// 检查重复
if let Some(pos) = store
.repos
.iter()
.position(|r| r.owner == repo.owner && r.name == repo.name)
{
store.repos[pos] = repo;
} else {
store.repos.push(repo);
}
Ok(())
}
/// 删除仓库
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
store
.repos
.retain(|r| !(r.owner == owner && r.name == name));
Ok(())
}
}

View File

@@ -16,6 +16,20 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -32,7 +46,14 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
@@ -57,7 +78,10 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false,
claude_config_dir: None,
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(),
}
@@ -89,6 +113,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.gemini_config_dir = self
.gemini_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -171,6 +202,27 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -186,3 +238,11 @@ pub fn get_codex_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_gemini_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.gemini_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}

View File

@@ -33,15 +33,15 @@ pub async fn execute_usage_script(
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {}", e),
format!("Failed to create JS runtime: {}", e),
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {}", e),
format!("Failed to create JS context: {}", e),
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
@@ -50,8 +50,8 @@ pub async fn execute_usage_script(
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_parse_failed",
format!("解析配置失败: {}", e),
format!("Failed to parse config: {}", e),
format!("解析配置失败: {e}"),
format!("Failed to parse config: {e}"),
)
})?;
@@ -59,8 +59,8 @@ pub async fn execute_usage_script(
let request: rquickjs::Object = config.get("request").map_err(|e| {
AppError::localized(
"usage_script.request_missing",
format!("缺少 request 配置: {}", e),
format!("Missing request config: {}", e),
format!("缺少 request 配置: {e}"),
format!("Missing request config: {e}"),
)
})?;
@@ -70,8 +70,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.request_serialize_failed",
format!("序列化 request 失败: {}", e),
format!("Failed to serialize request: {}", e),
format!("序列化 request 失败: {e}"),
format!("Failed to serialize request: {e}"),
)
})?
.ok_or_else(|| {
@@ -85,8 +85,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {}", e),
format!("Failed to get string: {}", e),
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
@@ -98,8 +98,8 @@ pub async fn execute_usage_script(
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
AppError::localized(
"usage_script.request_format_invalid",
format!("request 配置格式错误: {}", e),
format!("Invalid request config format: {}", e),
format!("request 配置格式错误: {e}"),
format!("Invalid request config format: {e}"),
)
})?;
@@ -111,15 +111,15 @@ pub async fn execute_usage_script(
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {}", e),
format!("Failed to create JS runtime: {}", e),
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {}", e),
format!("Failed to create JS context: {}", e),
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
@@ -128,8 +128,8 @@ pub async fn execute_usage_script(
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_reparse_failed",
format!("重新解析配置失败: {}", e),
format!("Failed to re-parse config: {}", e),
format!("重新解析配置失败: {e}"),
format!("Failed to re-parse config: {e}"),
)
})?;
@@ -137,8 +137,8 @@ pub async fn execute_usage_script(
let extractor: Function = config.get("extractor").map_err(|e| {
AppError::localized(
"usage_script.extractor_missing",
format!("缺少 extractor 函数: {}", e),
format!("Missing extractor function: {}", e),
format!("缺少 extractor 函数: {e}"),
format!("Missing extractor function: {e}"),
)
})?;
@@ -147,8 +147,8 @@ pub async fn execute_usage_script(
ctx.json_parse(response_data.as_str()).map_err(|e| {
AppError::localized(
"usage_script.response_parse_failed",
format!("解析响应 JSON 失败: {}", e),
format!("Failed to parse response JSON: {}", e),
format!("解析响应 JSON 失败: {e}"),
format!("Failed to parse response JSON: {e}"),
)
})?;
@@ -156,8 +156,8 @@ pub async fn execute_usage_script(
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
AppError::localized(
"usage_script.extractor_exec_failed",
format!("执行 extractor 失败: {}", e),
format!("Failed to execute extractor: {}", e),
format!("执行 extractor 失败: {e}"),
format!("Failed to execute extractor: {e}"),
)
})?;
@@ -167,8 +167,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.result_serialize_failed",
format!("序列化结果失败: {}", e),
format!("Failed to serialize result: {}", e),
format!("序列化结果失败: {e}"),
format!("Failed to serialize result: {e}"),
)
})?
.ok_or_else(|| {
@@ -182,8 +182,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {}", e),
format!("Failed to get string: {}", e),
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
@@ -191,8 +191,8 @@ pub async fn execute_usage_script(
serde_json::from_str(&result_json).map_err(|e| {
AppError::localized(
"usage_script.json_parse_failed",
format!("JSON 解析失败: {}", e),
format!("JSON parse failed: {}", e),
format!("JSON 解析失败: {e}"),
format!("JSON parse failed: {e}"),
)
})
})?
@@ -225,8 +225,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {}", e),
format!("Failed to create client: {}", e),
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
@@ -255,8 +255,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
let resp = req.send().await.map_err(|e| {
AppError::localized(
"usage_script.request_failed",
format!("请求失败: {}", e),
format!("Request failed: {}", e),
format!("请求失败: {e}"),
format!("Request failed: {e}"),
)
})?;
@@ -264,8 +264,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
let text = resp.text().await.map_err(|e| {
AppError::localized(
"usage_script.read_response_failed",
format!("读取响应失败: {}", e),
format!("Failed to read response: {}", e),
format!("读取响应失败: {e}"),
format!("Failed to read response: {e}"),
)
})?;
@@ -277,8 +277,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
};
return Err(AppError::localized(
"usage_script.http_error",
format!("HTTP {} : {}", status, preview),
format!("HTTP {} : {}", status, preview),
format!("HTTP {status} : {preview}"),
format!("HTTP {status} : {preview}"),
));
}
@@ -300,8 +300,8 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
validate_single_usage(item).map_err(|e| {
AppError::localized(
"usage_script.array_validation_failed",
format!("数组索引[{}]验证失败: {}", idx, e),
format!("Validation failed at index [{}]: {}", idx, e),
format!("数组索引[{idx}]验证失败: {e}"),
format!("Validation failed at index [{idx}]: {e}"),
)
})?;
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.6.2",
"version": "3.7.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -14,17 +14,22 @@
{
"label": "main",
"title": "",
"width": 900,
"titleBarStyle": "Overlay",
"width": 1000,
"height": 650,
"minWidth": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Transparent"
"center": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:",
"assetProtocol": {
"enable": true,
"scope": []
}
}
},
"bundle": {
@@ -42,9 +47,17 @@
"wix": {
"template": "wix/per-user-main.wxs"
}
},
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["ccswitch"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [

View File

@@ -0,0 +1,121 @@
use std::sync::RwLock;
use cc_switch_lib::{
import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn deeplink_import_claude_provider_persists_to_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4";
let request = parse_deeplink_url(url).expect("parse deeplink url");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude);
let state = AppState {
config: RwLock::new(config),
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
// 验证内存状态
let guard = state.config.read().expect("read config");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager should exist");
let provider = manager
.providers
.get(&provider_id)
.expect("provider created via deeplink");
assert_eq!(provider.name, request.name);
assert_eq!(
provider.website_url.as_deref(),
Some(request.homepage.as_str())
);
let auth_token = provider
.settings_config
.pointer("/env/ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str());
let base_url = provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str());
assert_eq!(auth_token, Some(request.api_key.as_str()));
assert_eq!(base_url, Some(request.endpoint.as_str()));
drop(guard);
// 验证配置已持久化
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"importing provider from deeplink should persist config.json"
);
}
#[test]
fn deeplink_import_codex_provider_builds_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o";
let request = parse_deeplink_url(url).expect("parse deeplink url");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
let state = AppState {
config: RwLock::new(config),
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
let guard = state.config.read().expect("read config");
let manager = guard
.get_manager(&AppType::Codex)
.expect("codex manager should exist");
let provider = manager
.providers
.get(&provider_id)
.expect("provider created via deeplink");
assert_eq!(provider.name, request.name);
assert_eq!(
provider.website_url.as_deref(),
Some(request.homepage.as_str())
);
let auth_value = provider
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str());
let config_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(auth_value, Some(request.api_key.as_str()));
assert!(
config_text.contains(request.endpoint.as_str()),
"config.toml content should contain endpoint"
);
assert!(
config_text.contains("model = \"gpt-4o\""),
"config.toml content should contain model setting"
);
drop(guard);
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"importing provider from deeplink should persist config.json"
);
}

View File

@@ -4,7 +4,7 @@ use tauri::async_runtime;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
MultiAppConfig, Provider,
MultiAppConfig, Provider, ProviderMeta,
};
#[path = "support.rs"]
@@ -63,9 +63,7 @@ fn sync_claude_provider_writes_live_settings() {
// 额外确认写入位置位于测试 HOME 下
assert!(
settings_path.starts_with(home),
"settings path {:?} should reside under test HOME {:?}",
settings_path,
home
"settings path {settings_path:?} should reside under test HOME {home:?}"
);
}
@@ -224,10 +222,13 @@ mode = "dev"
text.contains("[profile]"),
"non-MCP table should be preserved"
);
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
assert!(
text.contains("mcp_servers") || text.contains("[mcp.servers]"),
"one server table style should be present"
text.contains("mcp_servers"),
"mcp_servers table should be present"
);
assert!(
!text.contains("[mcp.servers]"),
"invalid [mcp.servers] table should not appear"
);
assert!(
text.contains("echo") && text.contains("command = \"echo\""),
@@ -236,14 +237,14 @@ mode = "dev"
}
#[test]
fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
fn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
// 预置 mcp.servers 风格
// 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers
let seed = r#"[mcp]
other = "keep"
[mcp.servers]
@@ -262,14 +263,14 @@ fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let text = fs::read_to_string(&path).expect("read config.toml");
// 仍应采用 mcp.servers 风格
// 应迁移到顶层 mcp_servers并移除错误的 mcp.servers
assert!(
text.contains("[mcp.servers]"),
"should keep mcp.servers style"
text.contains("mcp_servers"),
"should migrate to mcp_servers table"
);
assert!(
!text.contains("mcp_servers"),
"should not switch to mcp_servers"
!text.contains("[mcp.servers]"),
"invalid [mcp.servers] table should be removed"
);
}
@@ -489,16 +490,19 @@ url = "https://example.com"
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
assert!(changed >= 2, "should import both servers");
let servers = &config.mcp.codex.servers;
let echo = servers
.get("echo_server")
.and_then(|v| v.as_object())
.expect("echo server");
assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true));
let server_spec = echo
.get("server")
.and_then(|v| v.as_object())
.expect("server spec");
// v3.7.0: 检查统一结构
let servers = config
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let echo = servers.get("echo_server").expect("echo server");
assert!(
echo.apps.codex,
"Codex app should be enabled for echo_server"
);
let server_spec = echo.server.as_object().expect("server spec");
assert_eq!(
server_spec
.get("command")
@@ -507,14 +511,12 @@ url = "https://example.com"
"echo"
);
let http = servers
.get("http_server")
.and_then(|v| v.as_object())
.expect("http server");
let http_spec = http
.get("server")
.and_then(|v| v.as_object())
.expect("http spec");
let http = servers.get("http_server").expect("http server");
assert!(
http.apps.codex,
"Codex app should be enabled for http_server"
);
let http_spec = http.server.as_object().expect("http spec");
assert_eq!(
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
"https://example.com"
@@ -539,36 +541,51 @@ command = "echo"
.expect("write codex config");
let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert(
"existing".into(),
json!({
"id": "existing",
"name": "existing",
"enabled": false,
"server": {
// v3.7.0: 在统一结构中创建已存在的服务器
config.mcp.servers = Some(std::collections::HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"existing".to_string(),
cc_switch_lib::McpServer {
id: "existing".to_string(),
name: "existing".to_string(),
server: json!({
"type": "stdio",
"command": "prev"
}
}),
}),
apps: cc_switch_lib::McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
assert!(changed >= 1, "should mark change for enabled flag");
// v3.7.0: 检查统一结构
let entry = config
.mcp
.codex
.servers
.as_ref()
.unwrap()
.get("existing")
.and_then(|v| v.as_object())
.expect("existing entry");
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
let spec = entry
.get("server")
.and_then(|v| v.as_object())
.expect("server spec");
// 保留原 command确保导入不会覆盖现有 server 细节
assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev"));
// 验证 Codex 应用已启用
assert!(entry.apps.codex, "Codex app should be enabled after import");
// 验证现有配置被保留server 不应被覆盖)
let spec = entry.server.as_object().expect("server spec");
assert_eq!(
spec.get("command").and_then(|v| v.as_str()),
Some("prev"),
"existing server config should be preserved, not overwritten by import"
);
}
#[test]
@@ -646,34 +663,49 @@ fn import_from_claude_merges_into_config() {
.expect("write claude json");
let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert(
"stdio-enabled".into(),
json!({
"id": "stdio-enabled",
"name": "stdio-enabled",
"enabled": false,
"server": {
// v3.7.0: 在统一结构中创建已存在的服务器
config.mcp.servers = Some(std::collections::HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"stdio-enabled".to_string(),
cc_switch_lib::McpServer {
id: "stdio-enabled".to_string(),
name: "stdio-enabled".to_string(),
server: json!({
"type": "stdio",
"command": "prev"
}
}),
}),
apps: cc_switch_lib::McpApps {
claude: false, // 初始未启用
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
assert!(changed >= 1, "should mark at least one change");
// v3.7.0: 检查统一结构
let entry = config
.mcp
.claude
.servers
.as_ref()
.unwrap()
.get("stdio-enabled")
.and_then(|v| v.as_object())
.expect("entry exists");
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
let server = entry
.get("server")
.and_then(|v| v.as_object())
.expect("server obj");
// 验证 Claude 应用已启用
assert!(
entry.apps.claude,
"Claude app should be enabled after import"
);
// 验证现有配置被保留server 不应被覆盖)
let server = entry.server.as_object().expect("server obj");
assert_eq!(
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
"prev",
@@ -909,6 +941,121 @@ fn import_config_from_path_missing_file_produces_io_error() {
}
}
#[test]
fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-1".to_string();
manager.providers.insert(
"packy-1".to_string(),
Provider::with_id(
"packy-1".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType"
);
}
#[test]
fn sync_gemini_google_official_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
let cc_settings = home.join(".cc-switch").join("settings.json");
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should also record oauth-personal"
);
}
#[test]
fn export_config_to_file_writes_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");

View File

@@ -1,10 +1,10 @@
use std::{fs, sync::RwLock};
use std::{collections::HashMap, fs, sync::RwLock};
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppState, AppType, McpService, MultiAppConfig,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
@@ -126,16 +126,18 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
);
let guard = state.config.read().expect("lock config");
let claude_servers = &guard.mcp.claude.servers;
let entry = claude_servers
// v3.7.0: 检查统一结构
let servers = guard
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers
.get("echo")
.expect("server imported into config.json");
.expect("server imported into unified structure");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"imported server should be marked enabled"
entry.apps.claude,
"imported server should have Claude app enabled"
);
drop(guard);
@@ -181,43 +183,63 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
fn set_mcp_enabled_for_codex_writes_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
let home = ensure_test_home();
// 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("auth.json"),
r#"{"OPENAI_API_KEY":"test-key"}"#,
)
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
config.mcp.codex.servers.insert(
// v3.7.0: 使用统一结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"codex-server".into(),
json!({
"id": "codex-server",
"name": "Codex Server",
"server": {
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
"enabled": false
}),
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = AppState {
config: RwLock::new(config),
};
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
.expect("set enabled should succeed");
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let guard = state.config.read().expect("lock config");
let entry = guard
.mcp
.codex
.servers
.as_ref()
.unwrap()
.get("codex-server")
.expect("codex server exists");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"server should be marked enabled after command"
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
drop(guard);

View File

@@ -3,7 +3,7 @@ use std::sync::RwLock;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
MultiAppConfig, Provider, ProviderService,
MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
@@ -139,6 +139,188 @@ command = "say"
);
}
#[test]
fn switch_packycode_gemini_updates_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-gemini".to_string();
manager.providers.insert(
"packy-gemini".to_string(),
Provider::with_id(
"packy-gemini".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"PackyCode Gemini should set security.auth.selectedType"
);
}
#[test]
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-meta".to_string();
let mut provider = Provider::with_id(
"packy-meta".to_string(),
"Generic Gemini".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-meta",
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
}
}),
Some("https://example.com".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("packycode".to_string()),
..ProviderMeta::default()
});
manager.providers.insert("packy-meta".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"Partner meta should set security.auth.selectedType even without packy keywords"
);
}
#[test]
fn switch_google_official_gemini_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should also reflect oauth-personal"
);
}
#[test]
fn provider_service_switch_claude_updates_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -321,8 +503,8 @@ fn provider_service_delete_codex_removes_provider_and_files() {
let sanitized = sanitize_provider_name("DeleteCodex");
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
let auth_path = codex_dir.join(format!("auth-{}.json", sanitized));
let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized));
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
@@ -384,7 +566,7 @@ fn provider_service_delete_claude_removes_provider_files() {
let sanitized = sanitize_provider_name("DeleteClaude");
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
let by_name = claude_dir.join(format!("settings-{}.json", sanitized));
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
let by_id = claude_dir.join("settings-delete.json");
std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id");

View File

@@ -23,7 +23,7 @@ pub fn ensure_test_home() -> &'static Path {
/// 清理测试目录中生成的配置文件与缓存。
pub fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch"] {
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = std::fs::remove_dir_all(&path) {

View File

@@ -1,8 +1,18 @@
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";
import {
providersApi,
@@ -10,6 +20,7 @@ import {
type AppId,
type ProviderSwitchEvent,
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -17,27 +28,42 @@ 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";
import McpPanel from "@/components/mcp/McpPanel";
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";
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 [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 {
@@ -72,6 +98,64 @@ function App() {
};
}, [activeApp, refetch]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on startup:",
error,
);
}
};
checkEnvOnStartup();
}, []);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
try {
const conflicts = await checkEnvConflicts(activeApp);
if (conflicts.length > 0) {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
);
const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
);
return [...prev, ...newConflicts];
});
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on app switch:",
error,
);
}
};
checkEnvOnSwitch();
}, [activeApp]);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
@@ -160,80 +244,276 @@ 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">
<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"
: ""
<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);
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
setEnvConflicts(flatConflicts);
if (flatConflicts.length === 0) {
setShowEnvBanner(false);
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
} catch (error) {
console.error(
"[App] Failed to re-check conflicts after deletion:",
error,
);
}
}}
/>
)}
<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={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</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
@@ -281,17 +561,7 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<McpPanel
open={isMcpOpen}
onOpenChange={setIsMcpOpen}
appId={activeApp}
/>
<DeepLinkImportDialog />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import type { AppId } from "@/lib/api";
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons";
interface AppSwitcherProps {
activeApp: AppId;
@@ -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,13 +39,40 @@ 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>
<button
type="button"
onClick={() => handleSwitch("gemini")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<GeminiIcon
size={16}
className={
activeApp === "gemini"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Gemini</span>
</button>
</div>
);
}

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

@@ -0,0 +1,430 @@
import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
interface DeeplinkError {
url: string;
error: string;
}
export function DeepLinkImportDialog() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Listen for deep link import events
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
async (event) => {
console.log("Deep link import event received:", 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);
},
);
// Listen for deep link error events
const unlistenError = listen<DeeplinkError>("deeplink-error", (event) => {
console.error("Deep link error:", event.payload);
toast.error(t("deeplink.parseError"), {
description: event.payload.error,
});
});
return () => {
unlistenImport.then((fn) => fn());
unlistenError.then((fn) => fn());
};
}, [t]);
const handleImport = async () => {
if (!request) return;
setIsImporting(true);
try {
await deeplinkApi.importFromDeeplink(request);
// Invalidate provider queries to refresh the list
await queryClient.invalidateQueries({
queryKey: ["providers", request.app],
});
toast.success(t("deeplink.importSuccess"), {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
});
setIsOpen(false);
} catch (error) {
console.error("Failed to import provider from deep link:", error);
toast.error(t("deeplink.importError"), {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setIsImporting(false);
}
};
const handleCancel = () => {
setIsOpen(false);
};
// Mask API key for display (show first 4 chars + ***)
const maskedApiKey =
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 && !!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 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>
{/* 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>
<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,159 @@
import React, { useRef, useEffect } from "react";
import { EditorView, basicSetup } from "codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder as placeholderExt } from "@codemirror/view";
interface MarkdownEditorProps {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
readOnly?: boolean;
className?: string;
minHeight?: string;
maxHeight?: string;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder: placeholderText = "",
darkMode = false,
readOnly = false,
className = "",
minHeight = "300px",
maxHeight,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
// 定义基础主题
const baseTheme = EditorView.baseTheme({
"&": {
height: "100%",
minHeight,
maxHeight: maxHeight || "none",
},
".cm-scroller": {
overflow: "auto",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
"&light .cm-content, &dark .cm-content": {
padding: "12px 0",
},
"&light .cm-editor, &dark .cm-editor": {
backgroundColor: "transparent",
},
"&.cm-focused": {
outline: "none",
},
});
const extensions = [
basicSetup,
markdown(),
baseTheme,
EditorView.lineWrapping,
EditorState.readOnly.of(readOnly),
];
if (!readOnly) {
extensions.push(
placeholderExt(placeholderText),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
}
}),
);
} else {
// 只读模式下隐藏光标和高亮行
extensions.push(
EditorView.theme({
".cm-cursor, .cm-dropCursor": { border: "none" },
".cm-activeLine": { backgroundColor: "transparent !important" },
".cm-activeLineGutter": { backgroundColor: "transparent !important" },
}),
);
}
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
} else {
// 浅色模式下的简单样式调整,使其更融入 UI
extensions.push(
EditorView.theme(
{
"&": {
backgroundColor: "transparent",
},
".cm-content": {
color: "#374151", // text-gray-700
},
".cm-gutters": {
backgroundColor: "#f9fafb", // bg-gray-50
color: "#9ca3af", // text-gray-400
borderRight: "1px solid #e5e7eb", // border-gray-200
},
".cm-activeLineGutter": {
backgroundColor: "#e5e7eb",
},
},
{ dark: false },
),
);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, readOnly, minHeight, maxHeight, placeholderText]); // 添加 placeholderText 依赖以支持国际化切换
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
const transaction = viewRef.current.state.update({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
viewRef.current.dispatch(transaction);
}
}, [value]);
return (
<div
ref={editorRef}
className={`border rounded-md overflow-hidden ${
darkMode ? "border-gray-800" : "border-gray-200"
} ${className}`}
/>
);
};
export default MarkdownEditor;

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 && (
@@ -153,14 +156,13 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${
isExpired
className={`font-semibold tabular-nums ${isExpired
? "text-red-500 dark:text-red-400"
: firstUsage.remaining <
(firstUsage.total || firstUsage.remaining) * 0.1
(firstUsage.total || firstUsage.remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
}`}
>
{firstUsage.remaining.toFixed(2)}
</span>
@@ -179,7 +181,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 +198,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" : ""} />
@@ -308,13 +310,12 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${
isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
className={`font-semibold tabular-nums ${isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
>
{remaining.toFixed(2)}
</span>

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,8 +126,53 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [testing, setTesting] = useState(false);
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
// 初始化:如果已有 accessToken 或 userId说明是 NewAPI 模板
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
const num = Number(value);
if (isNaN(num) || value.trim() === "") {
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;
}
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
);
}
if (num < 0) {
toast.error(
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
);
return 0;
}
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;
};
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
@@ -143,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();
};
@@ -167,7 +202,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 使用当前编辑器中的脚本内容进行测试
const result = await usageApi.testScript(
provider.id,
appId,
@@ -179,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}]` : "";
@@ -234,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,
@@ -246,7 +277,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
// 通用:保留 apiKey 和 baseUrl清空 NewAPI 字段
setScript({
...script,
code: preset,
@@ -254,89 +284,135 @@ 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;
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">
<Label htmlFor="usage-api-key">
API Key
</Label>
<Label htmlFor="usage-api-key">API Key</Label>
<div className="relative">
<Input
id="usage-api-key"
@@ -347,24 +423,31 @@ 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"
aria-label={showApiKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
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")
: t("apiKeyInput.show")
}
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">
Base URL
</Label>
<Label htmlFor="usage-base-url">Base URL</Label>
<Input
id="usage-base-url"
type="text"
@@ -374,18 +457,16 @@ 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">
<Label htmlFor="usage-newapi-base-url">
Base URL
</Label>
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
<Input
id="usage-newapi-base-url"
type="text"
@@ -395,6 +476,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="https://api.newapi.com"
autoComplete="off"
className="border-white/10"
/>
</div>
@@ -408,19 +490,35 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
type={showAccessToken ? "text" : "password"}
value={script.accessToken || ""}
onChange={(e) =>
setScript({ ...script, accessToken: e.target.value })
setScript({
...script,
accessToken: e.target.value,
})
}
placeholder={t("usageScript.accessTokenPlaceholder")}
placeholder={t(
"usageScript.accessTokenPlaceholder",
)}
autoComplete="off"
className="border-white/10"
/>
{script.accessToken && (
<button
type="button"
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"
aria-label={showAccessToken ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
onClick={() =>
setShowAccessToken(!showAccessToken)
}
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")
: t("apiKeyInput.show")
}
>
{showAccessToken ? <EyeOff size={16} /> : <Eye size={16} />}
{showAccessToken ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
@@ -439,34 +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")}
{/* 脚本配置 */}
<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>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
<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")}
@@ -474,64 +608,150 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<Input
id="usage-timeout"
type="number"
min={2}
max={30}
value={script.timeout || 10}
onChange={(e) =>
setScript({
...script,
timeout: parseInt(e.target.value),
})
}
/>
</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 || 0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
autoQueryInterval: parseInt(e.target.value) || 0,
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,
@@ -539,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,
);
};

274
src/components/env/EnvWarningBanner.tsx vendored Normal file
View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ChevronDown, ChevronUp, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import type { EnvConflict } from "@/types/env";
import { deleteEnvVars } from "@/lib/api/env";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface EnvWarningBannerProps {
conflicts: EnvConflict[];
onDismiss: () => void;
onDeleted: () => void;
}
export function EnvWarningBanner({
conflicts,
onDismiss,
onDeleted,
}: EnvWarningBannerProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedConflicts, setSelectedConflicts] = useState<Set<string>>(
new Set(),
);
const [isDeleting, setIsDeleting] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
if (conflicts.length === 0) {
return null;
}
const toggleSelection = (key: string) => {
const newSelection = new Set(selectedConflicts);
if (newSelection.has(key)) {
newSelection.delete(key);
} else {
newSelection.add(key);
}
setSelectedConflicts(newSelection);
};
const toggleSelectAll = () => {
if (selectedConflicts.size === conflicts.length) {
setSelectedConflicts(new Set());
} else {
setSelectedConflicts(
new Set(conflicts.map((c) => `${c.varName}:${c.sourcePath}`)),
);
}
};
const handleDelete = async () => {
setShowConfirmDialog(false);
setIsDeleting(true);
try {
const conflictsToDelete = conflicts.filter((c) =>
selectedConflicts.has(`${c.varName}:${c.sourcePath}`),
);
if (conflictsToDelete.length === 0) {
toast.warning(t("env.error.noSelection"));
return;
}
const backupInfo = await deleteEnvVars(conflictsToDelete);
toast.success(t("env.delete.success"), {
description: t("env.backup.location", {
path: backupInfo.backupPath,
}),
duration: 5000,
});
// 清空选择并通知父组件
setSelectedConflicts(new Set());
onDeleted();
} catch (error) {
console.error("删除环境变量失败:", error);
toast.error(t("env.delete.error"), {
description: String(error),
});
} finally {
setIsDeleting(false);
}
};
const getSourceDescription = (conflict: EnvConflict): string => {
if (conflict.sourceType === "system") {
if (conflict.sourcePath.includes("HKEY_CURRENT_USER")) {
return t("env.source.userRegistry");
} else if (conflict.sourcePath.includes("HKEY_LOCAL_MACHINE")) {
return t("env.source.systemRegistry");
} else {
return t("env.source.systemEnv");
}
} else {
return conflict.sourcePath;
}
};
return (
<>
<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" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
{t("env.warning.title")}
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-0.5">
{t("env.warning.description", { count: conflicts.length })}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
{isExpanded ? (
<>
{t("env.actions.collapse")}
<ChevronUp className="h-4 w-4 ml-1" />
</>
) : (
<>
{t("env.actions.expand")}
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{isExpanded && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-900/50">
<Checkbox
id="select-all"
checked={selectedConflicts.size === conflicts.length}
onCheckedChange={toggleSelectAll}
/>
<label
htmlFor="select-all"
className="text-sm font-medium text-yellow-900 dark:text-yellow-100 cursor-pointer"
>
{t("env.actions.selectAll")}
</label>
</div>
<div className="max-h-96 overflow-y-auto space-y-2">
{conflicts.map((conflict) => {
const key = `${conflict.varName}:${conflict.sourcePath}`;
return (
<div
key={key}
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-900 rounded-md border border-yellow-200 dark:border-yellow-900/50"
>
<Checkbox
id={key}
checked={selectedConflicts.has(key)}
onCheckedChange={() => toggleSelection(key)}
/>
<div className="flex-1 min-w-0">
<label
htmlFor={key}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
{conflict.varName}
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{t("env.field.source")}:{" "}
{getSourceDescription(conflict)}
</p>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-yellow-200 dark:border-yellow-900/50">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedConflicts(new Set())}
disabled={selectedConflicts.size === 0}
className="text-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-800"
>
{t("env.actions.clearSelection")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setShowConfirmDialog(true)}
disabled={selectedConflicts.size === 0 || isDeleting}
className="gap-1"
>
<Trash2 className="h-4 w-4" />
{isDeleting
? t("env.actions.deleting")
: t("env.actions.deleteSelected", {
count: selectedConflicts.size,
})}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("env.confirm.title")}
</DialogTitle>
<DialogDescription className="space-y-2">
<p>
{t("env.confirm.message", { count: selectedConflicts.size })}
</p>
<p className="text-sm text-muted-foreground">
{t("env.confirm.backupNotice")}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t("env.confirm.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,25 +1,12 @@
import React, { useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { mcpApi, type AppId } from "@/lib/api";
import JsonEditor from "@/components/JsonEditor";
import type { AppId } from "@/lib/api/types";
import { McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
import McpWizardModal from "./McpWizardModal";
@@ -33,38 +20,36 @@ import {
mcpServerToToml,
} from "@/utils/tomlUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parseSmartMcpJson } from "@/utils/formatters";
import { useMcpValidation } from "./useMcpValidation";
import { useUpsertMcpServer } from "@/hooks/useMcp";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
interface McpFormModalProps {
appId: AppId;
editingId?: string;
initialData?: McpServer;
onSave: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
onSave: () => Promise<void>;
onClose: () => void;
existingIds?: string[];
defaultFormat?: "json" | "toml";
defaultEnabledApps?: AppId[];
}
/**
* MCP 表单模态框组件(简化版)
* Claude: 使用 JSON 格式
* Codex: 使用 TOML 格式
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude", "codex", "gemini"],
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
useMcpValidation();
const upsertMutation = useUpsertMcpServer();
const [formId, setFormId] = useState(
() => editingId || initialData?.id || "",
);
@@ -76,10 +61,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 编辑模式下禁止修改 ID
const [enabledApps, setEnabledApps] = useState<{
claude: boolean;
codex: boolean;
gemini: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
}
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
};
});
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
@@ -87,16 +85,21 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 根据 appId 决定初始格式
const useTomlFormat = useMemo(() => {
if (initialData?.server) {
return defaultFormat === "toml";
}
return defaultFormat === "toml";
}, [defaultFormat, initialData]);
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
if (appId === "codex") {
if (useTomlFormat) {
return mcpServerToToml(spec);
}
return JSON.stringify(spec, null, 2);
@@ -106,39 +109,24 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [syncOtherSide, setSyncOtherSide] = useState(false);
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 判断是否使用 TOML 格式
const useToml = appId === "codex";
const syncTargetLabel =
appId === "claude" ? t("apps.codex") : t("apps.claude");
const otherAppType: AppId = appId === "claude" ? "codex" : "claude";
const syncCheckboxId = useMemo(() => `sync-other-side-${appId}`, [appId]);
// 检测另一侧是否有同名 MCP
useEffect(() => {
const checkOtherSide = async () => {
const currentId = formId.trim();
if (!currentId) {
setOtherSideHasConflict(false);
return;
}
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const otherConfig = await mcpApi.getConfig(otherAppType);
const hasConflict = Object.keys(otherConfig.servers || {}).includes(
currentId,
);
setOtherSideHasConflict(hasConflict);
} catch (error) {
console.error("检查另一侧 MCP 配置失败:", error);
setOtherSideHasConflict(false);
}
};
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
checkOtherSide();
}, [formId, otherAppType]);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const useToml = useTomlFormat;
const wizardInitialSpec = useMemo(() => {
const fallback = initialData?.server;
@@ -165,7 +153,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}, [formConfig, initialData, useToml]);
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
@@ -187,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];
@@ -201,7 +187,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
@@ -214,10 +199,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
@@ -229,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) {
@@ -249,15 +229,31 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON validation (use hook's complete validation)
const err = validateJsonConfig(value);
if (err) {
setConfigError(err);
return;
try {
const result = parseSmartMcpJson(value);
const configJson = JSON.stringify(result.config);
const validationErr = validateJsonConfig(configJson);
if (validationErr) {
setConfigError(validationErr);
return;
}
if (result.id && !formId.trim() && !isEditing) {
const uniqueId = ensureUniqueId(result.id);
setFormId(uniqueId);
if (!formName.trim()) {
setFormName(result.id);
}
}
setConfigError("");
} catch (err: any) {
const errorMessage = err?.message || String(err);
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
}
}
setConfigError("");
};
const handleWizardApply = (title: string, json: string) => {
@@ -265,7 +261,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formName.trim()) {
setFormName(title);
}
// Wizard returns JSON, convert based on format if needed
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
@@ -288,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) {
@@ -307,7 +299,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -324,16 +315,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON mode
const jsonError = validateJsonConfig(formConfig);
setConfigError(jsonError);
if (jsonError) {
toast.error(t("mcp.error.jsonInvalid"), { duration: 3000 });
return;
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -341,45 +323,42 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
} else {
try {
serverSpec = JSON.parse(formConfig) as McpServerSpec;
const result = parseSmartMcpJson(formConfig);
serverSpec = result.config as McpServerSpec;
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
const errorMessage = e?.message || String(e);
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
return;
}
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
}
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
if (
(serverSpec?.type === "http" || serverSpec?.type === "sse") &&
!serverSpec?.url?.trim()
) {
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return;
}
setSaving(true);
try {
const nameTrimmed = (formName || trimmedId).trim();
const finalName = nameTrimmed || trimmedId;
const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
name: finalName,
server: serverSpec,
apps: enabledApps,
};
// 修复:新增 MCP 时默认启用enabled=true
// 编辑模式下保留原有的 enabled 状态
if (initialData?.enabled !== undefined) {
entry.enabled = initialData.enabled;
} else {
// 新增模式:默认启用
entry.enabled = true;
}
const nameTrimmed = (formName || trimmedId).trim();
entry.name = nameTrimmed || trimmedId;
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
@@ -411,8 +390,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
delete entry.tags;
}
// 显式等待父组件保存流程
await onSave(trimmedId, entry, { syncOtherSide });
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
await onSave();
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -424,27 +404,38 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const getFormTitle = () => {
if (appId === "claude") {
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
return isEditing ? t("mcp.editServer") : t("mcp.addServer");
};
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">
@@ -454,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")}
@@ -469,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)}
>
@@ -480,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 && (
@@ -503,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
@@ -514,12 +506,68 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* 启用到哪些应用 */}
<div>
<label className="block text-sm font-medium text-foreground mb-3">
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="enable-claude"
checked={enabledApps.claude}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, claude: checked })
}
/>
<label
htmlFor="enable-claude"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-codex"
checked={enabledApps.codex}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, codex: checked })
}
/>
<label
htmlFor="enable-codex"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-gemini"
checked={enabledApps.gemini}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, gemini: checked })
}
/>
<label
htmlFor="enable-gemini"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
</div>
</div>
</div>
{/* 可折叠的附加信息按钮 */}
<div>
<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} />
@@ -533,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
@@ -546,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
@@ -559,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
@@ -572,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
@@ -586,100 +630,51 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</>
)}
</div>
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block 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>
)}
{/* 下半部分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>
<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)}
/>
{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-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* 双端同步选项 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<input
id={syncCheckboxId}
type="checkbox"
className="h-4 w-4 rounded border-border-default text-emerald-600 focus:ring-emerald-500 dark:bg-gray-800"
checked={syncOtherSide}
onChange={(event) => setSyncOtherSide(event.target.checked)}
/>
<label
htmlFor={syncCheckboxId}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
title={t("mcp.form.syncOtherSideHint", {
target: syncTargetLabel,
})}
>
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
</label>
</div>
{syncOtherSide && otherSideHasConflict && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<AlertTriangle size={14} />
<span className="text-xs font-medium">
{t("mcp.form.willOverwriteWarning", {
target: syncTargetLabel,
})}
</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<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>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FullScreenPanel>
{/* Wizard Modal */}
<McpWizardModal

View File

@@ -1,122 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { McpServer } from "@/types";
import { mcpPresets } from "@/config/mcpPresets";
import McpToggle from "./McpToggle";
interface McpListItemProps {
id: string;
server: McpServer;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
/**
* MCP 列表项组件
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
*/
const McpListItem: React.FC<McpListItemProps> = ({
id,
server,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await settingsApi.openExternal(url);
} catch {
// ignore
}
};
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="flex items-center gap-4 h-full">
{/* 左侧Toggle 开关 */}
<div className="flex-shrink-0">
<McpToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{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 items-center gap-2 flex-shrink-0">
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
<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>
</div>
);
};
export default McpListItem;

View File

@@ -1,229 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Server, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { type AppId } from "@/lib/api";
import { McpServer } from "@/types";
import { useMcpActions } from "@/hooks/useMcpActions";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
interface McpPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ open, onOpenChange, appId }) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
// Use MCP actions hook
const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } =
useMcpActions(appId);
useEffect(() => {
const setup = async () => {
try {
// Initialize: only import existing MCPs from corresponding client
if (appId === "claude") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromClaude();
} else if (appId === "codex") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromCodex();
}
} catch (e) {
console.warn("MCP initialization import failed (ignored)", e);
} finally {
await reload();
}
};
setup();
// Re-initialize when appId changes
}, [appId, reload]);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await deleteServer(id);
setConfirmDialog(null);
} catch (e) {
// Error already handled by useMcpActions
}
},
});
};
const handleSave = async (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => {
await saveServer(id, server, options);
setIsFormOpen(false);
setEditingId(null);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const enabledCount = useMemo(
() => serverEntries.filter(([_, server]) => server.enabled).length,
[serverEntries],
);
const panelTitle =
appId === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
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("mcp.add")}
</Button>
</div>
</DialogHeader>
{/* 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: Object.keys(servers).length })} ·{" "}
{t("mcp.enabledCount", { count: enabledCount })}
</div>
</div>
{/* Content - Scrollable */}
<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("mcp.loading")}
</div>
) : (
(() => {
const hasAny = serverEntries.length > 0;
if (!hasAny) {
return (
<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.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
</div>
);
})()
)}
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)}
onSave={handleSave}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
);
};
export default McpPanel;

View File

@@ -80,13 +80,15 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
initialServer,
}) => {
const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
"stdio",
);
const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState("");
const [wizardEnv, setWizardEnv] = useState("");
// http 字段
// http 和 sse 字段
const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState("");
@@ -115,7 +117,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}
}
} else {
// http 类型必需字段
// http 和 sse 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
@@ -139,7 +141,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
}
if (wizardType === "http" && !wizardUrl.trim()) {
if ((wizardType === "http" || wizardType === "sse") && !wizardUrl.trim()) {
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return;
}
@@ -179,7 +181,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
setWizardType(resolvedType);
if (resolvedType === "http") {
if (resolvedType === "http" || resolvedType === "sse") {
setWizardUrl(initialServer?.url ?? "");
const headersCandidate = initialServer?.headers;
const headers =
@@ -250,7 +252,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
value="stdio"
checked={wizardType === "stdio"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
@@ -264,7 +266,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
value="http"
checked={wizardType === "http"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
@@ -272,6 +274,20 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.typeHttp")}
</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="sse"
checked={wizardType === "sse"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")}
</span>
</label>
</div>
</div>
@@ -339,8 +355,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
{/* HTTP 和 SSE 类型字段 */}
{(wizardType === "http" || wizardType === "sse") && (
<>
{/* URL */}
<div>

View File

@@ -0,0 +1,345 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { useDeleteMcpServer } from "@/hooks/useMcp";
import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
interface UnifiedMcpPanelProps {
onOpenChange: (open: boolean) => void;
}
/**
* 统一 MCP 管理面板
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
*/
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);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
// Queries and Mutations
const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer();
// Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => {
if (!serversMap) return [];
return Object.entries(serversMap);
}, [serversMap]);
// Count enabled servers per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
serverEntries.forEach(([_, server]) => {
if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++;
});
return counts;
}, [serverEntries]);
const handleToggleApp = async (
serverId: string,
app: AppId,
enabled: boolean,
) => {
try {
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.unifiedPanel.deleteServer"),
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
onConfirm: async () => {
try {
await deleteServerMutation.mutateAsync(id);
setConfirmDialog(null);
toast.success(t("common.success"));
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
return (
<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>
{/* 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>
) : 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>
)}
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
editingId={editingId || undefined}
initialData={
editingId && serversMap ? serversMap[editingId] : undefined
}
existingIds={serversMap ? Object.keys(serversMap) : []}
defaultFormat="json"
onSave={async () => {
setIsFormOpen(false);
setEditingId(null);
}}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
});
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
/**
* 统一 MCP 列表项组件
* 展示服务器名称、描述,以及三个应用的复选框
*/
interface UnifiedMcpListItemProps {
id: string;
server: McpServer;
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
id,
server,
onToggleApp,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
const name = server.name || id;
const description = server.description || "";
// 匹配预设元信息
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await settingsApi.openExternal(url);
} catch {
// ignore
}
};
return (
<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>
{/* 右侧:操作按钮 */}
<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>
);
};
export default UnifiedMcpPanel;

View File

@@ -41,7 +41,10 @@ export function useMcpValidation() {
if (server.type === "stdio" && !server.command?.trim()) {
return t("mcp.error.commandRequired");
}
if (server.type === "http" && !server.url?.trim()) {
if (
(server.type === "http" || server.type === "sse") &&
!server.url?.trim()
) {
return t("mcp.wizard.urlRequired");
}
} catch (e: any) {
@@ -73,7 +76,7 @@ export function useMcpValidation() {
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
return t("mcp.error.commandRequired");
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) {
return t("mcp.wizard.urlRequired");
}
}

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MarkdownEditor from "@/components/MarkdownEditor";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormModalProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormModal: React.FC<PromptFormModalProps> = ({
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"));
// 监听 html 元素的 class 变化以实时响应主题切换
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);
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
<div>
<Label htmlFor="name">{t("prompts.name")}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
/>
</div>
<div>
<Label htmlFor="description">{t("prompts.description")}</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
/>
</div>
<div>
<Label htmlFor="content" className="mb-2 block">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="300px"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || !content.trim() || saving}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFormModal;

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

@@ -0,0 +1,75 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Prompt } from "@/lib/api";
import PromptToggle from "./PromptToggle";
interface PromptListItemProps {
id: string;
prompt: Prompt;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const PromptListItem: React.FC<PromptListItemProps> = ({
id,
prompt,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
const enabled = prompt.enabled === true;
return (
<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">
<PromptToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{prompt.name}
</h3>
{prompt.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{prompt.description}
</p>
)}
</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>
</div>
);
};
export default PromptListItem;

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import { ConfirmDialog } from "../ConfirmDialog";
interface PromptPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
}
export interface PromptPanelHandle {
openAdd: () => void;
}
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);
const {
prompts,
loading,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
} = usePromptActions(appId);
useEffect(() => {
if (open) reload();
}, [open, reload]);
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleEdit = (id: string) => {
setEditingId(id);
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
}
},
});
};
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
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 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>
<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>
{isFormOpen && (
<PromptFormPanel
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>
);
},
);
PromptPanel.displayName = "PromptPanel";
export default PromptPanel;

View File

@@ -1,16 +1,16 @@
import React from "react";
interface McpToggleProps {
interface PromptToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}
/**
* Toggle
* 绿
* Toggle
*
*/
const McpToggle: React.FC<McpToggleProps> = ({
const PromptToggle: React.FC<PromptToggleProps> = ({
enabled,
onChange,
disabled = false,
@@ -23,8 +23,8 @@ const McpToggle: React.FC<McpToggleProps> = ({
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
@@ -38,4 +38,4 @@ const McpToggle: React.FC<McpToggleProps> = ({
);
};
export default McpToggle;
export default PromptToggle;

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 {
@@ -18,6 +11,7 @@ import {
} from "@/components/providers/forms/ProviderForm";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
interface AddProviderDialogProps {
open: boolean;
@@ -44,8 +38,11 @@ export function AddProviderDialog({
// 构造基础提交数据
const providerData: Omit<Provider, "id"> = {
name: values.name.trim(),
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 } : {}),
};
@@ -56,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) => {
@@ -96,6 +91,21 @@ export function AddProviderDialog({
preset.endpointCandidates.forEach(addUrl);
}
}
} else if (appId === "gemini") {
const presets = geminiProviderPresets;
const presetIndex = parseInt(
values.presetId.replace("gemini-", ""),
);
if (
!isNaN(presetIndex) &&
presetIndex >= 0 &&
presetIndex < presets.length
) {
const preset = presets[presetIndex];
if (Array.isArray(preset.endpointCandidates)) {
preset.endpointCandidates.forEach(addUrl);
}
}
}
}
@@ -114,6 +124,11 @@ export function AddProviderDialog({
addUrl(baseUrlMatch[1]);
}
}
} else if (appId === "gemini") {
const env = parsedConfig.env as Record<string, any> | undefined;
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
}
const urls = Array.from(urlSet);
@@ -144,36 +159,44 @@ export function AddProviderDialog({
const submitLabel =
appId === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
: appId === "codex"
? 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
@@ -93,8 +86,11 @@ export function EditProviderDialog({
const updatedProvider: Provider = {
...provider,
name: values.name.trim(),
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 } : {}),
@@ -111,44 +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,
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;
@@ -33,14 +32,23 @@ interface ProviderCardProps {
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
// 优先级 1: 备注
if (provider.notes?.trim()) {
return provider.notes.trim();
}
// 优先级 2: 官网地址
if (provider.websiteUrl) {
return provider.websiteUrl;
}
// 优先级 3: 从配置中提取请求地址
const config = provider.settingsConfig;
if (config && typeof config === "object") {
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
const envBase =
(config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||
(config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;
if (typeof envBase === "string" && envBase.trim()) {
return envBase;
}
@@ -62,7 +70,6 @@ export function ProviderCard({
provider,
isCurrent,
appId,
isEditMode = false,
onSwitch,
onEdit,
onDelete,
@@ -81,10 +88,24 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
// 判断是否为可点击的 URL备注不可点击
const isClickableUrl = useMemo(() => {
// 如果有备注,则不可点击
if (provider.notes?.trim()) {
return false;
}
// 如果显示的是回退文本,也不可点击
if (displayUrl === fallbackUrlText) {
return false;
}
// 其他情况(官网地址或请求地址)可点击
return true;
}, [provider.notes, displayUrl, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const handleOpenWebsite = () => {
if (!displayUrl || displayUrl === fallbackUrlText) {
if (!isClickableUrl) {
return;
}
onOpenWebsite(displayUrl);
@@ -93,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">
@@ -147,6 +155,17 @@ export function ProviderCard({
<h3 className="text-base font-semibold leading-none">
{provider.name}
</h3>
{provider.category === "third_party" &&
provider.meta?.isPartner && (
<span
className="text-yellow-500 dark:text-yellow-400"
title={t("provider.officialPartner", {
defaultValue: "官方合作伙伴",
})}
>
</span>
)}
<span
className={cn(
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
@@ -161,8 +180,14 @@ export function ProviderCard({
<button
type="button"
onClick={handleOpenWebsite}
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400"
className={cn(
"inline-flex items-center text-sm max-w-[280px]",
isClickableUrl
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
: "text-muted-foreground cursor-default",
)}
title={displayUrl}
disabled={!isClickableUrl}
>
<span className="truncate">{displayUrl}</span>
</button>
@@ -170,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}

View File

@@ -34,7 +34,7 @@ interface ClaudeFormFieldsProps {
onBaseUrlChange: (url: string) => void;
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
// Model Selector
shouldShowModelSelector: boolean;

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>
);
};

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