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
1279
deplink.html
@@ -46,6 +46,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons-static-svg": "^1.73.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
8
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
|
||||
'@lobehub/icons-static-svg':
|
||||
specifier: ^1.73.0
|
||||
version: 1.73.0
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -609,6 +612,9 @@ packages:
|
||||
'@lezer/markdown@1.6.0':
|
||||
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0':
|
||||
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
@@ -2839,6 +2845,8 @@ snapshots:
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0': {}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mswjs/interceptors@0.40.0':
|
||||
|
||||
208
scripts/extract-icons.js
Normal file
@@ -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
@@ -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');
|
||||
113
scripts/generate-icon-index.js
Normal 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}`);
|
||||
45
src-tauri/Cargo.lock
generated
@@ -291,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"
|
||||
@@ -595,15 +606,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.7.0"
|
||||
version = "3.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto-launch",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rquickjs",
|
||||
@@ -982,6 +996,15 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
@@ -1000,6 +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"
|
||||
@@ -6397,6 +6431,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -48,6 +48,9 @@ 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"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-start-dragging",
|
||||
"process:allow-restart",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
40
src-tauri/src/auto_launch.rs
Normal 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}")))
|
||||
}
|
||||
@@ -9,6 +9,16 @@ pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
||||
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Merge configuration from Base64/URL into a deep link request
|
||||
/// This is used by the frontend to show the complete configuration in the confirmation dialog
|
||||
#[tauri::command]
|
||||
pub fn merge_deeplink_config(
|
||||
request: DeepLinkImportRequest,
|
||||
) -> Result<DeepLinkImportRequest, String> {
|
||||
log::info!("Merging config for deep link request: {}", request.name);
|
||||
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Import a provider from a deep link request (after user confirmation)
|
||||
#[tauri::command]
|
||||
pub fn import_from_deeplink(
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -56,26 +56,20 @@ pub async fn install_skill(
|
||||
|
||||
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,
|
||||
)
|
||||
})?,
|
||||
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()
|
||||
|
||||
@@ -37,6 +37,24 @@ pub struct DeepLinkImportRequest {
|
||||
/// Optional notes/description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
/// Optional Haiku model (Claude only, v3.7.1+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haiku_model: Option<String>,
|
||||
/// Optional Sonnet model (Claude only, v3.7.1+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sonnet_model: Option<String>,
|
||||
/// Optional Opus model (Claude only, v3.7.1+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opus_model: Option<String>,
|
||||
/// Optional Base64 encoded config content (v3.8+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<String>,
|
||||
/// Optional config format (json/toml, v3.8+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_format: Option<String>,
|
||||
/// Optional remote config URL (v3.8+)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
|
||||
@@ -110,29 +128,33 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
|
||||
.clone();
|
||||
|
||||
let homepage = params
|
||||
.get("homepage")
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
|
||||
.clone();
|
||||
// Make these optional for config file auto-fill (v3.8+)
|
||||
let homepage = params.get("homepage").cloned().unwrap_or_default();
|
||||
let endpoint = params.get("endpoint").cloned().unwrap_or_default();
|
||||
let api_key = params.get("apiKey").cloned().unwrap_or_default();
|
||||
|
||||
let endpoint = params
|
||||
.get("endpoint")
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
|
||||
.clone();
|
||||
|
||||
let api_key = params
|
||||
.get("apiKey")
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
|
||||
.clone();
|
||||
|
||||
// Validate URLs
|
||||
validate_url(&homepage, "homepage")?;
|
||||
validate_url(&endpoint, "endpoint")?;
|
||||
// Validate URLs only if provided
|
||||
if !homepage.is_empty() {
|
||||
validate_url(&homepage, "homepage")?;
|
||||
}
|
||||
if !endpoint.is_empty() {
|
||||
validate_url(&endpoint, "endpoint")?;
|
||||
}
|
||||
|
||||
// Extract optional fields
|
||||
let model = params.get("model").cloned();
|
||||
let notes = params.get("notes").cloned();
|
||||
|
||||
// Extract Claude-specific optional model fields (v3.7.1+)
|
||||
let haiku_model = params.get("haikuModel").cloned();
|
||||
let sonnet_model = params.get("sonnetModel").cloned();
|
||||
let opus_model = params.get("opusModel").cloned();
|
||||
|
||||
// Extract optional config fields (v3.8+)
|
||||
let config = params.get("config").cloned();
|
||||
let config_format = params.get("configFormat").cloned();
|
||||
let config_url = params.get("configUrl").cloned();
|
||||
|
||||
Ok(DeepLinkImportRequest {
|
||||
version,
|
||||
resource,
|
||||
@@ -143,6 +165,12 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
|
||||
api_key,
|
||||
model,
|
||||
notes,
|
||||
haiku_model,
|
||||
sonnet_model,
|
||||
opus_model,
|
||||
config,
|
||||
config_format,
|
||||
config_url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,23 +193,44 @@ fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Validates the request
|
||||
/// 2. Converts it to a Provider structure
|
||||
/// 3. Delegates to ProviderService for actual import
|
||||
/// 2. Merges config file if provided (v3.8+)
|
||||
/// 3. Converts it to a Provider structure
|
||||
/// 4. Delegates to ProviderService for actual import
|
||||
pub fn import_provider_from_deeplink(
|
||||
state: &AppState,
|
||||
request: DeepLinkImportRequest,
|
||||
) -> Result<String, AppError> {
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Step 2: Validate required fields after merge
|
||||
if merged_request.api_key.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"API key is required (either in URL or config file)".to_string(),
|
||||
));
|
||||
}
|
||||
if merged_request.endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint is required (either in URL or config file)".to_string(),
|
||||
));
|
||||
}
|
||||
if merged_request.homepage.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Homepage is required (either in URL or config file)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(&request.app)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
|
||||
let app_type = AppType::from_str(&merged_request.app)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
let mut provider = build_provider_from_request(&app_type, &request)?;
|
||||
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
|
||||
|
||||
// Generate a unique ID for the provider using timestamp + sanitized name
|
||||
// This is similar to how frontend generates IDs
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let sanitized_name = request
|
||||
let sanitized_name = merged_request
|
||||
.name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
@@ -211,11 +260,31 @@ fn build_provider_from_request(
|
||||
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
|
||||
|
||||
// Add model if provided (use as default model)
|
||||
// Add default model if provided
|
||||
if let Some(model) = &request.model {
|
||||
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
||||
}
|
||||
|
||||
// Add Claude-specific model fields (v3.7.1+)
|
||||
if let Some(haiku_model) = &request.haiku_model {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||||
json!(haiku_model),
|
||||
);
|
||||
}
|
||||
if let Some(sonnet_model) = &request.sonnet_model {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||||
json!(sonnet_model),
|
||||
);
|
||||
}
|
||||
if let Some(opus_model) = &request.opus_model {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
|
||||
json!(opus_model),
|
||||
);
|
||||
}
|
||||
|
||||
json!({ "env": env })
|
||||
}
|
||||
AppType::Codex => {
|
||||
@@ -319,11 +388,254 @@ requires_openai_auth = true
|
||||
sort_index: None,
|
||||
notes: request.notes.clone(),
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
};
|
||||
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Parse and merge configuration from Base64 encoded config or remote URL
|
||||
///
|
||||
/// Priority: URL params > inline config > remote config
|
||||
pub fn parse_and_merge_config(
|
||||
request: &DeepLinkImportRequest,
|
||||
) -> Result<DeepLinkImportRequest, AppError> {
|
||||
use base64::prelude::*;
|
||||
|
||||
// If no config provided, return original request
|
||||
if request.config.is_none() && request.config_url.is_none() {
|
||||
return Ok(request.clone());
|
||||
}
|
||||
|
||||
// Step 1: Get config content
|
||||
let config_content = if let Some(config_b64) = &request.config {
|
||||
// Decode Base64 inline config
|
||||
let decoded = BASE64_STANDARD
|
||||
.decode(config_b64)
|
||||
.map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?;
|
||||
String::from_utf8(decoded)
|
||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
|
||||
} else if let Some(_config_url) = &request.config_url {
|
||||
// Fetch remote config (TODO: implement remote fetching in next phase)
|
||||
return Err(AppError::InvalidInput(
|
||||
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
|
||||
));
|
||||
} else {
|
||||
return Ok(request.clone());
|
||||
};
|
||||
|
||||
// Step 2: Parse config based on format
|
||||
let format = request.config_format.as_deref().unwrap_or("json");
|
||||
let config_value: serde_json::Value = match format {
|
||||
"json" => serde_json::from_str(&config_content)
|
||||
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
|
||||
"toml" => {
|
||||
let toml_value: toml::Value = toml::from_str(&config_content)
|
||||
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
|
||||
// Convert TOML to JSON for uniform processing
|
||||
serde_json::to_value(toml_value)
|
||||
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Unsupported config format: {format}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Extract values from config based on app type and merge with URL params
|
||||
let mut merged = request.clone();
|
||||
|
||||
match request.app.as_str() {
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
_ => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: {}",
|
||||
request.app
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Merge Claude configuration from config file
|
||||
///
|
||||
/// Priority: URL params override config file values
|
||||
fn merge_claude_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let env = config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
AppError::InvalidInput("Claude config must have 'env' object".to_string())
|
||||
})?;
|
||||
|
||||
// Auto-fill API key if not provided in URL
|
||||
if request.api_key.is_empty() {
|
||||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||||
request.api_key = token.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill endpoint if not provided in URL
|
||||
if request.endpoint.is_empty() {
|
||||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = base_url.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint if not provided
|
||||
if request.homepage.is_empty() && !request.endpoint.is_empty() {
|
||||
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
|
||||
.unwrap_or_else(|| "https://anthropic.com".to_string());
|
||||
}
|
||||
|
||||
// Auto-fill model fields (URL params take priority)
|
||||
if request.model.is_none() {
|
||||
request.model = env
|
||||
.get("ANTHROPIC_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if request.haiku_model.is_none() {
|
||||
request.haiku_model = env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if request.sonnet_model.is_none() {
|
||||
request.sonnet_model = env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if request.opus_model.is_none() {
|
||||
request.opus_model = env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge Codex configuration from config file
|
||||
fn merge_codex_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
||||
if request.api_key.is_empty() {
|
||||
if let Some(api_key) = config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.api_key = api_key.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill endpoint and model from config string
|
||||
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
||||
// Parse TOML config string to extract base_url and model
|
||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||||
// Extract base_url from model_providers section
|
||||
if request.endpoint.is_empty() {
|
||||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||||
request.endpoint = base_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract model
|
||||
if request.model.is_none() {
|
||||
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
|
||||
request.model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.is_empty() && !request.endpoint.is_empty() {
|
||||
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
|
||||
.unwrap_or_else(|| "https://openai.com".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge Gemini configuration from config file
|
||||
fn merge_gemini_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Gemini uses flat env structure
|
||||
if request.api_key.is_empty() {
|
||||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
request.api_key = api_key.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if request.endpoint.is_empty() {
|
||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = base_url.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if request.model.is_none() {
|
||||
request.model = config
|
||||
.get("GEMINI_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.is_empty() && !request.endpoint.is_empty() {
|
||||
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
|
||||
.unwrap_or_else(|| "https://ai.google.dev".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract base_url from Codex TOML config
|
||||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
// Try to find base_url in model_providers section
|
||||
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
|
||||
for (_key, provider) in providers.iter() {
|
||||
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
|
||||
return Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Infer homepage URL from API endpoint
|
||||
///
|
||||
/// Examples:
|
||||
/// - https://api.anthropic.com/v1 → https://anthropic.com
|
||||
/// - https://api.openai.com/v1 → https://openai.com
|
||||
/// - https://api-test.company.com/v1 → https://company.com
|
||||
fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
|
||||
let url = Url::parse(endpoint).ok()?;
|
||||
let host = url.host_str()?;
|
||||
|
||||
// Remove common API prefixes
|
||||
let clean_host = host
|
||||
.strip_prefix("api.")
|
||||
.or_else(|| host.strip_prefix("api-"))
|
||||
.unwrap_or(host);
|
||||
|
||||
Some(format!("https://{clean_host}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -375,14 +687,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_required_field() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
|
||||
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude";
|
||||
|
||||
let result = parse_deeplink_url(url);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Missing 'homepage' parameter"));
|
||||
.contains("Missing 'name' parameter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,6 +726,12 @@ mod tests {
|
||||
api_key: "test-api-key".to_string(),
|
||||
model: Some("gemini-2.0-flash".to_string()),
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
};
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||
@@ -443,6 +762,12 @@ mod tests {
|
||||
api_key: "test-api-key".to_string(),
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
};
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||
@@ -454,4 +779,88 @@ mod tests {
|
||||
// Model should not be present
|
||||
assert!(env.get("GEMINI_MODEL").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage() {
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
|
||||
Some("https://anthropic.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
|
||||
Some("https://test.company.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://example.com"),
|
||||
Some("https://example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_claude() {
|
||||
use base64::prelude::*;
|
||||
|
||||
// Prepare Base64 encoded Claude config
|
||||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||||
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: "claude".to_string(),
|
||||
name: "Test".to_string(),
|
||||
homepage: String::new(),
|
||||
endpoint: String::new(),
|
||||
api_key: String::new(),
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: Some(config_b64),
|
||||
config_format: Some("json".to_string()),
|
||||
config_url: None,
|
||||
};
|
||||
|
||||
let merged = parse_and_merge_config(&request).unwrap();
|
||||
|
||||
// Should auto-fill from config
|
||||
assert_eq!(merged.api_key, "sk-ant-xxx");
|
||||
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
|
||||
assert_eq!(merged.homepage, "https://anthropic.com");
|
||||
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_url_override() {
|
||||
use base64::prelude::*;
|
||||
|
||||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||||
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: "claude".to_string(),
|
||||
name: "Test".to_string(),
|
||||
homepage: String::new(),
|
||||
endpoint: String::new(),
|
||||
api_key: "sk-new".to_string(), // URL param should override
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: Some(config_b64),
|
||||
config_format: Some("json".to_string()),
|
||||
config_url: None,
|
||||
};
|
||||
|
||||
let merged = parse_and_merge_config(&request).unwrap();
|
||||
|
||||
// URL param should take priority
|
||||
assert_eq!(merged.api_key, "sk-new");
|
||||
// Config file value should be used
|
||||
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,6 @@ pub fn format_skill_error(
|
||||
|
||||
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
|
||||
// 如果 JSON 序列化失败,返回简单格式
|
||||
format!("ERROR:{}", code)
|
||||
format!("ERROR:{code}")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app_config;
|
||||
mod app_store;
|
||||
mod auto_launch;
|
||||
mod claude_mcp;
|
||||
mod claude_plugin;
|
||||
mod codex_config;
|
||||
@@ -14,6 +15,7 @@ mod mcp;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod store;
|
||||
@@ -704,6 +706,7 @@ pub fn run() {
|
||||
commands::sync_current_providers_live,
|
||||
// Deep link import
|
||||
commands::parse_deeplink,
|
||||
commands::merge_deeplink_config,
|
||||
commands::import_from_deeplink,
|
||||
update_tray_menu,
|
||||
// Environment variable management
|
||||
@@ -717,6 +720,9 @@ pub fn run() {
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
// Auto launch
|
||||
commands::set_auto_launch,
|
||||
commands::get_auto_launch_status,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -28,6 +28,13 @@ pub struct Provider {
|
||||
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
/// 图标名称(如 "openai", "anthropic")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// 图标颜色(Hex 格式,如 "#00A67E")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -48,6 +55,8 @@ impl Provider {
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
238
src-tauri/src/provider_defaults.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,9 @@ impl SkillService {
|
||||
// 根据 skills_path 确定源目录路径
|
||||
let source = if let Some(ref skills_path) = repo.skills_path {
|
||||
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
|
||||
temp_dir.join(skills_path.trim_matches('/')).join(&directory)
|
||||
temp_dir
|
||||
.join(skills_path.trim_matches('/'))
|
||||
.join(&directory)
|
||||
} else {
|
||||
// 否则源路径为: temp_dir/directory
|
||||
temp_dir.join(&directory)
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct AppSettings {
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
/// 是否开机自启
|
||||
#[serde(default)]
|
||||
pub launch_on_startup: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub security: Option<SecuritySettings>,
|
||||
/// Claude 自定义端点列表
|
||||
@@ -77,6 +80,7 @@ impl Default for AppSettings {
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
language: None,
|
||||
launch_on_startup: false,
|
||||
security: None,
|
||||
custom_endpoints_claude: HashMap::new(),
|
||||
custom_endpoints_codex: HashMap::new(),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "",
|
||||
"titleBarStyle": "Overlay",
|
||||
"width": 1000,
|
||||
"height": 650,
|
||||
"minWidth": 900,
|
||||
|
||||
395
src/App.tsx
@@ -1,7 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Settings, Edit3 } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Settings,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Book,
|
||||
Wrench,
|
||||
Server,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
import { useProvidersQuery } from "@/lib/query";
|
||||
@@ -19,7 +28,7 @@ import { ProviderList } from "@/components/providers/ProviderList";
|
||||
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
@@ -27,34 +36,34 @@ import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
|
||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<View>("providers");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
const addActionButtonClass =
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const isClaudeApp = activeApp === "claude";
|
||||
|
||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||
const {
|
||||
@@ -98,7 +107,10 @@ function App() {
|
||||
|
||||
if (flatConflicts.length > 0) {
|
||||
setEnvConflicts(flatConflicts);
|
||||
setShowEnvBanner(true);
|
||||
const dismissed = sessionStorage.getItem("env_banner_dismissed");
|
||||
if (!dismissed) {
|
||||
setShowEnvBanner(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -128,7 +140,10 @@ function App() {
|
||||
);
|
||||
return [...prev, ...newConflicts];
|
||||
});
|
||||
setShowEnvBanner(true);
|
||||
const dismissed = sessionStorage.getItem("env_banner_dismissed");
|
||||
if (!dismissed) {
|
||||
setShowEnvBanner(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -229,13 +244,81 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case "settings":
|
||||
return (
|
||||
<SettingsPage
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
);
|
||||
case "prompts":
|
||||
return (
|
||||
<PromptPanel
|
||||
ref={promptPanelRef}
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
appId={activeApp}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
onClose={() => setCurrentView("providers")}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return (
|
||||
<UnifiedMcpPanel
|
||||
ref={mcpPanelRef}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<div
|
||||
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
|
||||
style={{ overflowX: "hidden" }}
|
||||
>
|
||||
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 h-4 z-[60]"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
/>
|
||||
{/* 环境变量警告横幅 */}
|
||||
{showEnvBanner && envConflicts.length > 0 && (
|
||||
<EnvWarningBanner
|
||||
conflicts={envConflicts}
|
||||
onDismiss={() => setShowEnvBanner(false)}
|
||||
onDismiss={() => {
|
||||
setShowEnvBanner(false);
|
||||
sessionStorage.setItem("env_banner_dismissed", "true");
|
||||
}}
|
||||
onDeleted={async () => {
|
||||
// 删除后重新检测
|
||||
try {
|
||||
@@ -255,92 +338,182 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
CC Switch
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
title={t("common.settings")}
|
||||
className="ml-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
title={t(
|
||||
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
|
||||
)}
|
||||
className={
|
||||
isEditMode
|
||||
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||
<header
|
||||
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
>
|
||||
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
|
||||
<div
|
||||
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView !== "providers" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentView("providers")}
|
||||
className="mr-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{currentView === "settings" && t("settings.title")}
|
||||
{currentView === "prompts" &&
|
||||
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && "Agents"}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
CC Switch
|
||||
</a>
|
||||
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCurrentView("settings")}
|
||||
title={t("common.settings")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
<Button
|
||||
variant="mcp"
|
||||
onClick={() => setIsPromptOpen(true)}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t("prompts.manage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="mcp"
|
||||
onClick={() => setIsMcpOpen(true)}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
MCP
|
||||
</Button>
|
||||
<Button
|
||||
variant="mcp"
|
||||
onClick={() => setIsSkillsOpen(true)}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t("skills.manage")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("header.addProvider")}
|
||||
</Button>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className={addActionButtonClass}
|
||||
title={t("prompts.add")}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className={addActionButtonClass}
|
||||
title={t("mcp.unifiedPanel.addServer")}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
|
||||
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
|
||||
|
||||
<div className="glass p-1 rounded-xl flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="h-4 w-4" />
|
||||
</Button>
|
||||
{isClaudeApp && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skills")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="MCP"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</Button>
|
||||
{isClaudeApp && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("agents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="Agents"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-scroll">
|
||||
<div className="mx-auto max-w-4xl px-6 py-6">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
isEditMode={isEditMode}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<main
|
||||
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
|
||||
currentView === "providers" ? "pt-24" : "pt-20"
|
||||
}`}
|
||||
style={{ overflowX: "hidden" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
<AddProviderDialog
|
||||
@@ -388,30 +561,6 @@ function App() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
open={isSettingsOpen}
|
||||
onOpenChange={setIsSettingsOpen}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
<PromptPanel
|
||||
open={isPromptOpen}
|
||||
onOpenChange={setIsPromptOpen}
|
||||
appId={activeApp}
|
||||
/>
|
||||
|
||||
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||
|
||||
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{t("skills.title")}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
</DialogHeader>
|
||||
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeepLinkImportDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,13 +13,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
|
||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("claude")}
|
||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "claude"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
@@ -27,8 +27,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "claude"
|
||||
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
||||
? "text-foreground"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>Claude</span>
|
||||
@@ -39,11 +39,18 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
onClick={() => handleSwitch("codex")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "codex"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<CodexIcon size={16} />
|
||||
<CodexIcon
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "codex"
|
||||
? "text-foreground"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
|
||||
@@ -52,7 +59,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
onClick={() => handleSwitch("gemini")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "gemini"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
@@ -60,8 +67,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "gemini"
|
||||
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
|
||||
? "text-foreground"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>Gemini</span>
|
||||
|
||||
76
src/components/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||
import {
|
||||
@@ -30,9 +30,30 @@ export function DeepLinkImportDialog() {
|
||||
// Listen for deep link import events
|
||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||
"deeplink-import",
|
||||
(event) => {
|
||||
async (event) => {
|
||||
console.log("Deep link import event received:", event.payload);
|
||||
setRequest(event.payload);
|
||||
|
||||
// If config is present, merge it to get the complete configuration
|
||||
if (event.payload.config || event.payload.configUrl) {
|
||||
try {
|
||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||
event.payload,
|
||||
);
|
||||
console.log("Config merged successfully:", mergedRequest);
|
||||
setRequest(mergedRequest);
|
||||
} catch (error) {
|
||||
console.error("Failed to merge config:", error);
|
||||
toast.error(t("deeplink.configMergeError"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Fall back to original request
|
||||
setRequest(event.payload);
|
||||
}
|
||||
} else {
|
||||
setRequest(event.payload);
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
},
|
||||
);
|
||||
@@ -71,7 +92,6 @@ export function DeepLinkImportDialog() {
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setRequest(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to import provider from deep link:", error);
|
||||
toast.error(t("deeplink.importError"), {
|
||||
@@ -84,120 +104,326 @@ export function DeepLinkImportDialog() {
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
setRequest(null);
|
||||
};
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
// Mask API key for display (show first 4 chars + ***)
|
||||
const maskedApiKey =
|
||||
request.apiKey.length > 4
|
||||
request?.apiKey && request.apiKey.length > 4
|
||||
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
|
||||
: "****";
|
||||
|
||||
// Check if config file is present
|
||||
const hasConfigFile = !!(request?.config || request?.configUrl);
|
||||
const configSource = request?.config
|
||||
? "base64"
|
||||
: request?.configUrl
|
||||
? "url"
|
||||
: null;
|
||||
|
||||
// Parse config file content for display
|
||||
interface ParsedConfig {
|
||||
type: "claude" | "codex" | "gemini";
|
||||
env?: Record<string, string>;
|
||||
auth?: Record<string, string>;
|
||||
tomlConfig?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Helper to decode base64 with UTF-8 support
|
||||
const b64ToUtf8 = (str: string): string => {
|
||||
try {
|
||||
const binString = atob(str);
|
||||
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64:", e);
|
||||
return atob(str);
|
||||
}
|
||||
};
|
||||
|
||||
const parsedConfig = useMemo((): ParsedConfig | null => {
|
||||
if (!request?.config) return null;
|
||||
try {
|
||||
const decoded = b64ToUtf8(request.config);
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
|
||||
if (request.app === "claude") {
|
||||
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
|
||||
return {
|
||||
type: "claude",
|
||||
env: (parsed.env as Record<string, string>) || {},
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "codex") {
|
||||
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
|
||||
return {
|
||||
type: "codex",
|
||||
auth: (parsed.auth as Record<string, string>) || {},
|
||||
tomlConfig: (parsed.config as string) || "",
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "gemini") {
|
||||
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
|
||||
return {
|
||||
type: "gemini",
|
||||
env: parsed as Record<string, string>,
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse config:", e);
|
||||
return null;
|
||||
}
|
||||
}, [request?.config, request?.app]);
|
||||
|
||||
// Helper to mask sensitive values
|
||||
const maskValue = (key: string, value: string): string => {
|
||||
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
|
||||
const isSensitive = sensitiveKeys.some((k) =>
|
||||
key.toUpperCase().includes(k),
|
||||
);
|
||||
if (isSensitive && value.length > 8) {
|
||||
return `${value.substring(0, 8)}${"*".repeat(12)}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
{/* 标题显式左对齐,避免默认居中样式影响 */}
|
||||
<DialogHeader className="text-left sm:text-left">
|
||||
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deeplink.confirmImportDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={isOpen && !!request} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]" zIndex="top">
|
||||
{request && (
|
||||
<>
|
||||
{/* 标题显式左对齐,避免默认居中样式影响 */}
|
||||
<DialogHeader className="text-left sm:text-left">
|
||||
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deeplink.confirmImportDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
|
||||
<div className="space-y-4 px-8 py-4">
|
||||
{/* App Type */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.app")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium capitalize">
|
||||
{request.app}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Name */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.providerName")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium">{request.name}</div>
|
||||
</div>
|
||||
|
||||
{/* Homepage */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.homepage")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
|
||||
{request.homepage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key (masked) */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.apiKey")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||
{maskedApiKey}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model (if present) */}
|
||||
{request.model && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.model")}
|
||||
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
|
||||
<div className="space-y-4 px-8 py-4 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-200 dark:[&::-webkit-scrollbar-thumb]:bg-gray-700">
|
||||
{/* App Type */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.app")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium capitalize">
|
||||
{request.app}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono">
|
||||
{request.model}
|
||||
|
||||
{/* Provider Name */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.providerName")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium">
|
||||
{request.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Homepage */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.homepage")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
|
||||
{request.homepage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key (masked) */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.apiKey")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||
{maskedApiKey}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model (if present) */}
|
||||
{request.model && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.model")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono">
|
||||
{request.model}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes (if present) */}
|
||||
{request.notes && (
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.notes")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{request.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config File Details (v3.8+) */}
|
||||
{hasConfigFile && (
|
||||
<div className="space-y-3 pt-2 border-t border-border-default">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.configSource")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
{configSource === "base64"
|
||||
? t("deeplink.configEmbedded")
|
||||
: t("deeplink.configRemote")}
|
||||
</span>
|
||||
{request.configFormat && (
|
||||
<span className="ml-2 text-xs text-muted-foreground uppercase">
|
||||
{request.configFormat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parsed Config Details */}
|
||||
{parsedConfig && (
|
||||
<div className="rounded-lg bg-muted/50 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t("deeplink.configDetails")}
|
||||
</div>
|
||||
|
||||
{/* Claude config */}
|
||||
{parsedConfig.type === "claude" && parsedConfig.env && (
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(parsedConfig.env).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex config */}
|
||||
{parsedConfig.type === "codex" && (
|
||||
<div className="space-y-2">
|
||||
{parsedConfig.auth &&
|
||||
Object.keys(parsedConfig.auth).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Auth:
|
||||
</div>
|
||||
{Object.entries(parsedConfig.auth).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs pl-2"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parsedConfig.tomlConfig && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
TOML Config:
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
|
||||
{parsedConfig.tomlConfig.substring(0, 300)}
|
||||
{parsedConfig.tomlConfig.length > 300 && "..."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gemini config */}
|
||||
{parsedConfig.type === "gemini" && parsedConfig.env && (
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(parsedConfig.env).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config URL (if remote) */}
|
||||
{request.configUrl && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.configUrl")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono text-muted-foreground break-all">
|
||||
{request.configUrl}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{t("deeplink.warning")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes (if present) */}
|
||||
{request.notes && (
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.notes")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{request.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{t("deeplink.warning")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
85
src/components/IconPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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: "格式化" })}
|
||||
|
||||
81
src/components/ProviderIcon.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Play, Wand2, Eye, EyeOff } from "lucide-react";
|
||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider, UsageScript } from "@/types";
|
||||
@@ -8,17 +8,12 @@ import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
import * as parserBabel from "prettier/parser-babel";
|
||||
import * as pluginEstree from "prettier/plugins/estree";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
provider: Provider;
|
||||
@@ -131,88 +126,53 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// 🔧 输入时的格式化(宽松)- 只清理格式,不约束范围
|
||||
const sanitizeNumberInput = (value: string): string => {
|
||||
// 移除所有非数字字符
|
||||
let cleaned = value.replace(/[^\d]/g, "");
|
||||
|
||||
// 移除前导零(除非输入的就是 "0")
|
||||
if (cleaned.length > 1 && cleaned.startsWith("0")) {
|
||||
cleaned = cleaned.replace(/^0+/, "");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
// 🔧 失焦时的验证(严格)- 仅确保有效整数
|
||||
const validateTimeout = (value: string): number => {
|
||||
// 转换为数字
|
||||
const num = Number(value);
|
||||
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(num) || value.trim() === "") {
|
||||
return 10; // 默认值
|
||||
return 10;
|
||||
}
|
||||
|
||||
// 检查是否为整数
|
||||
if (!Number.isInteger(num)) {
|
||||
toast.warning(
|
||||
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
|
||||
);
|
||||
}
|
||||
|
||||
// 检查负数
|
||||
if (num < 0) {
|
||||
toast.error(
|
||||
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
|
||||
);
|
||||
return 10;
|
||||
}
|
||||
|
||||
return Math.floor(num);
|
||||
};
|
||||
|
||||
// 🔧 失焦时的验证(严格)- 自动查询间隔
|
||||
const validateAndClampInterval = (value: string): number => {
|
||||
// 转换为数字
|
||||
const num = Number(value);
|
||||
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(num) || value.trim() === "") {
|
||||
return 0; // 禁用自动查询
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 检查是否为整数
|
||||
if (!Number.isInteger(num)) {
|
||||
toast.warning(
|
||||
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
|
||||
);
|
||||
}
|
||||
|
||||
// 检查负数
|
||||
if (num < 0) {
|
||||
toast.error(
|
||||
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 约束到 [0, 1440] 范围(最大24小时)
|
||||
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
|
||||
|
||||
// 如果值被调整,显示提示
|
||||
if (clamped !== num && num > 0) {
|
||||
toast.info(
|
||||
t("usageScript.intervalAdjusted", { value: clamped }) ||
|
||||
`自动查询间隔已调整为 ${clamped} 分钟`,
|
||||
);
|
||||
}
|
||||
|
||||
return clamped;
|
||||
};
|
||||
|
||||
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
|
||||
// 初始化:如果已有 accessToken 或 userId,说明是 NewAPI 模板
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||
() => {
|
||||
const existingScript = provider.meta?.usage_script;
|
||||
@@ -223,23 +183,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
// 控制 API Key 的显示/隐藏
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAccessToken, setShowAccessToken] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
// 验证脚本格式
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本的 JS 语法检查(检查是否包含 return 语句)
|
||||
if (script.enabled && !script.code.includes("return")) {
|
||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(script);
|
||||
onClose();
|
||||
};
|
||||
@@ -247,7 +202,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// 使用当前编辑器中的脚本内容进行测试
|
||||
const result = await usageApi.testScript(
|
||||
provider.id,
|
||||
appId,
|
||||
@@ -259,7 +213,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
script.userId,
|
||||
);
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
// 显示所有套餐数据
|
||||
const summary = result.data
|
||||
.map((plan) => {
|
||||
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
||||
@@ -314,9 +267,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleUsePreset = (presetName: string) => {
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
// 根据模板类型清空不同的字段
|
||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||
// 自定义:清空所有凭证字段
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
@@ -326,7 +277,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
|
||||
// 通用:保留 apiKey 和 baseUrl,清空 NewAPI 字段
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
@@ -334,84 +284,131 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
|
||||
// NewAPI:清空 apiKey(NewAPI 不使用通用的 apiKey)
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
apiKey: undefined,
|
||||
});
|
||||
}
|
||||
setSelectedTemplate(presetName); // 记录选择的模板
|
||||
setSelectedTemplate(presetName);
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否应该显示凭证配置区域
|
||||
const shouldShowCredentialsConfig =
|
||||
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
||||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={!script.enabled || testing}
|
||||
>
|
||||
<Play size={14} className="mr-1" />
|
||||
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={!script.enabled}
|
||||
title={t("usageScript.format")}
|
||||
>
|
||||
<Wand2 size={14} className="mr-1" />
|
||||
{t("usageScript.format")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
{t("usageScript.saveConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usageScript.title")} - {provider.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={`${t("usageScript.title")} - ${provider.name}`}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none text-foreground">
|
||||
{t("usageScript.enableUsageQuery")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={script.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setScript({ ...script, enabled: checked })
|
||||
}
|
||||
aria-label={t("usageScript.enableUsageQuery")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{t("usageScript.enableUsageQuery")}
|
||||
</p>
|
||||
{script.enabled && (
|
||||
<div className="space-y-6">
|
||||
{/* 预设模板选择 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.presetTemplate")}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usageScript.variablesHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
return (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
isSelected
|
||||
? "shadow-sm"
|
||||
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
>
|
||||
{t(TEMPLATE_NAME_KEYS[name])}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Switch
|
||||
checked={script.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setScript({ ...script, enabled: checked })
|
||||
}
|
||||
aria-label={t("usageScript.enableUsageQuery")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{script.enabled && (
|
||||
<>
|
||||
{/* 预设模板选择 */}
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("usageScript.presetTemplate")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
isSelected
|
||||
? "bg-blue-500 text-white dark:bg-blue-600"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{t(TEMPLATE_NAME_KEYS[name])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 凭证配置 */}
|
||||
{shouldShowCredentialsConfig && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t("usageScript.credentialsConfig")}
|
||||
</h4>
|
||||
|
||||
{/* 凭证配置区域:通用和 NewAPI 模板显示 */}
|
||||
{shouldShowCredentialsConfig && (
|
||||
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("usageScript.credentialsConfig")}
|
||||
</h4>
|
||||
|
||||
{/* 通用模板:显示 apiKey + baseUrl */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -426,12 +423,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
placeholder="sk-xxxxx"
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
{script.apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t("apiKeyInput.hide")
|
||||
@@ -459,12 +457,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
placeholder="https://api.example.com"
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* NewAPI 模板:显示 baseUrl + accessToken + userId */}
|
||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -478,6 +476,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
placeholder="https://api.newapi.com"
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -500,6 +499,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
"usageScript.accessTokenPlaceholder",
|
||||
)}
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
{script.accessToken && (
|
||||
<button
|
||||
@@ -507,7 +507,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
onClick={() =>
|
||||
setShowAccessToken(!showAccessToken)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showAccessToken
|
||||
? t("apiKeyInput.hide")
|
||||
@@ -537,32 +537,70 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
placeholder={t("usageScript.userIdPlaceholder")}
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 脚本编辑器 */}
|
||||
<div>
|
||||
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
|
||||
<JsonEditor
|
||||
value={script.code}
|
||||
onChange={(code) => setScript({ ...script, code })}
|
||||
height="300px"
|
||||
language="javascript"
|
||||
{/* 脚本配置 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-medium text-foreground">
|
||||
{t("usageScript.scriptConfig")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.variablesHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-request-url">
|
||||
{t("usageScript.requestUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-request-url"
|
||||
type="text"
|
||||
value={script.request?.url || ""}
|
||||
onChange={(e) => {
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, url: e.target.value },
|
||||
});
|
||||
}}
|
||||
placeholder={t("usageScript.requestUrlPlaceholder")}
|
||||
className="border-white/10"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("usageScript.variablesHint", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 配置选项 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-method">
|
||||
{t("usageScript.method")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-method"
|
||||
type="text"
|
||||
value={script.request?.method || "GET"}
|
||||
onChange={(e) => {
|
||||
setScript({
|
||||
...script,
|
||||
request: {
|
||||
...script.request,
|
||||
method: e.target.value.toUpperCase(),
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="GET / POST"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-timeout">
|
||||
{t("usageScript.timeoutSeconds")}
|
||||
@@ -570,83 +608,150 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
<Input
|
||||
id="usage-timeout"
|
||||
type="number"
|
||||
value={script.timeout ?? ""}
|
||||
onChange={(e) => {
|
||||
// 输入时:只清理格式,允许临时为空,避免强制回填默认值
|
||||
const cleaned = sanitizeNumberInput(e.target.value);
|
||||
setScript((prev) => ({
|
||||
...prev,
|
||||
timeout:
|
||||
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
||||
}));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 失焦时:严格验证并约束范围
|
||||
const validated = validateTimeout(e.target.value);
|
||||
setScript({ ...script, timeout: validated });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.timeoutHint") || "范围: 2-30 秒"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 自动查询间隔 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-auto-interval">
|
||||
{t("usageScript.autoQueryInterval")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-auto-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
value={script.autoQueryInterval ?? ""}
|
||||
onChange={(e) => {
|
||||
// 输入时:只清理格式,允许临时为空
|
||||
const cleaned = sanitizeNumberInput(e.target.value);
|
||||
setScript((prev) => ({
|
||||
...prev,
|
||||
autoQueryInterval:
|
||||
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
||||
}));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 失焦时:严格验证并约束范围
|
||||
const validated = validateAndClampInterval(
|
||||
e.target.value,
|
||||
);
|
||||
setScript({ ...script, autoQueryInterval: validated });
|
||||
}}
|
||||
value={script.timeout ?? 10}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
onBlur={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
className="border-white/10"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 脚本说明 */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-headers">
|
||||
{t("usageScript.headers")}
|
||||
</Label>
|
||||
<JsonEditor
|
||||
id="usage-headers"
|
||||
value={
|
||||
script.request?.headers
|
||||
? JSON.stringify(script.request.headers, null, 2)
|
||||
: "{}"
|
||||
}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, headers: parsed },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Invalid headers JSON", error);
|
||||
}
|
||||
}}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
|
||||
<JsonEditor
|
||||
id="usage-body"
|
||||
value={
|
||||
script.request?.body
|
||||
? JSON.stringify(script.request.body, null, 2)
|
||||
: "{}"
|
||||
}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
const parsed =
|
||||
value?.trim() === "" ? undefined : JSON.parse(value);
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, body: parsed },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
|
||||
);
|
||||
}
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-interval">
|
||||
{t("usageScript.autoIntervalMinutes")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={script.autoIntervalMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
autoIntervalMinutes: validateAndClampInterval(
|
||||
e.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
onBlur={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
autoIntervalMinutes: validateAndClampInterval(
|
||||
e.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
className="border-white/10"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提取器代码 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) => setScript({ ...script, code: value })}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 帮助信息 */}
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"User-Agent": "cc-switch/1.0"
|
||||
},
|
||||
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
|
||||
}
|
||||
},
|
||||
extractor: function(response) {
|
||||
// ${t("usageScript.commentResponseIsJson")}
|
||||
return {
|
||||
isValid: !response.error,
|
||||
remaining: response.balance,
|
||||
@@ -654,79 +759,41 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
||||
{/* Left side - Test and Format buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={!script.enabled || testing}
|
||||
>
|
||||
<Play size={14} />
|
||||
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={!script.enabled}
|
||||
title={t("usageScript.format")}
|
||||
>
|
||||
<Wand2 size={14} />
|
||||
{t("usageScript.format")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right side - Cancel and Save buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSave}>
|
||||
{t("usageScript.saveConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
22
src/components/agents/AgentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/common/FullScreenPanel.tsx
Normal 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,
|
||||
);
|
||||
};
|
||||
4
src/components/env/EnvWarningBanner.tsx
vendored
@@ -110,7 +110,7 @@ export function EnvWarningBanner({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
|
||||
<div className="fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
@@ -241,7 +241,7 @@ export function EnvWarningBanner({
|
||||
</div>
|
||||
|
||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md" zIndex="top">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Save,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { McpServer, McpServerSpec } from "@/types";
|
||||
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
||||
@@ -34,25 +20,21 @@ import {
|
||||
mcpServerToToml,
|
||||
} from "@/utils/tomlUtils";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
import { formatJSON, parseSmartMcpJson } from "@/utils/formatters";
|
||||
import { parseSmartMcpJson } from "@/utils/formatters";
|
||||
import { useMcpValidation } from "./useMcpValidation";
|
||||
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
|
||||
interface McpFormModalProps {
|
||||
editingId?: string;
|
||||
initialData?: McpServer;
|
||||
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
||||
onSave: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
existingIds?: string[];
|
||||
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON)
|
||||
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为全部应用)
|
||||
defaultFormat?: "json" | "toml";
|
||||
defaultEnabledApps?: AppId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 表单模态框组件(v3.7.0 完整重构版)
|
||||
* - 支持 JSON 和 TOML 两种格式
|
||||
* - 统一管理,通过复选框选择启用到哪些应用
|
||||
*/
|
||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
editingId,
|
||||
initialData,
|
||||
@@ -79,7 +61,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
||||
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
||||
|
||||
// 启用状态:编辑模式使用现有值,新增模式使用默认值
|
||||
const [enabledApps, setEnabledApps] = useState<{
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
@@ -88,7 +69,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
}
|
||||
// 新增模式:根据 defaultEnabledApps 设置初始值
|
||||
return {
|
||||
claude: defaultEnabledApps.includes("claude"),
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
@@ -96,10 +76,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
// 编辑模式下禁止修改 ID
|
||||
const isEditing = !!editingId;
|
||||
|
||||
// 判断是否在编辑模式下有附加信息
|
||||
const hasAdditionalInfo = !!(
|
||||
initialData?.description ||
|
||||
initialData?.tags?.length ||
|
||||
@@ -107,21 +85,17 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
initialData?.docs
|
||||
);
|
||||
|
||||
// 附加信息展开状态(编辑模式下有值时默认展开)
|
||||
const [showMetadata, setShowMetadata] = useState(
|
||||
isEditing ? hasAdditionalInfo : false,
|
||||
);
|
||||
|
||||
// 配置格式:优先使用 defaultFormat,编辑模式下可从现有数据推断
|
||||
const useTomlFormat = useMemo(() => {
|
||||
if (initialData?.server) {
|
||||
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON)
|
||||
return defaultFormat === "toml";
|
||||
}
|
||||
return defaultFormat === "toml";
|
||||
}, [defaultFormat, initialData]);
|
||||
|
||||
// 根据格式决定初始配置
|
||||
const [formConfig, setFormConfig] = useState(() => {
|
||||
const spec = initialData?.server;
|
||||
if (!spec) return "";
|
||||
@@ -135,8 +109,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [idError, setIdError] = useState("");
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
|
||||
const useToml = useTomlFormat;
|
||||
|
||||
const wizardInitialSpec = useMemo(() => {
|
||||
@@ -164,7 +153,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
}, [formConfig, initialData, useToml]);
|
||||
|
||||
// 预设选择状态(仅新增模式显示;-1 表示自定义)
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
||||
isEditing ? null : -1,
|
||||
);
|
||||
@@ -186,7 +174,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
return `${candidate}-${i}`;
|
||||
};
|
||||
|
||||
// 应用预设(写入表单但不落库)
|
||||
const applyPreset = (index: number) => {
|
||||
if (index < 0 || index >= mcpPresets.length) return;
|
||||
const preset = mcpPresets[index];
|
||||
@@ -200,7 +187,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
setFormDocs(presetWithDesc.docs || "");
|
||||
setFormTags(presetWithDesc.tags?.join(", ") || "");
|
||||
|
||||
// 根据格式转换配置
|
||||
if (useToml) {
|
||||
const toml = mcpServerToToml(presetWithDesc.server);
|
||||
setFormConfig(toml);
|
||||
@@ -213,10 +199,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
setSelectedPreset(index);
|
||||
};
|
||||
|
||||
// 切回自定义
|
||||
const applyCustom = () => {
|
||||
setSelectedPreset(-1);
|
||||
// 恢复到空白模板
|
||||
setFormId("");
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
@@ -228,19 +212,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
};
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
// 若为 TOML 模式,先做引号归一化,避免中文输入法导致的格式错误
|
||||
const nextValue = useToml ? normalizeTomlText(value) : value;
|
||||
setFormConfig(nextValue);
|
||||
|
||||
if (useToml) {
|
||||
// TOML validation (use hook's complete validation)
|
||||
const err = validateTomlConfig(nextValue);
|
||||
if (err) {
|
||||
setConfigError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to extract ID (if user hasn't filled it yet)
|
||||
if (nextValue.trim() && !formId.trim()) {
|
||||
const extractedId = extractIdFromToml(nextValue);
|
||||
if (extractedId) {
|
||||
@@ -248,11 +229,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON validation with smart parsing
|
||||
try {
|
||||
const result = parseSmartMcpJson(value);
|
||||
|
||||
// 验证解析后的配置对象
|
||||
const configJson = JSON.stringify(result.config);
|
||||
const validationErr = validateJsonConfig(configJson);
|
||||
|
||||
@@ -261,20 +239,15 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动填充提取的 id(仅当表单 id 为空且不在编辑模式时)
|
||||
if (result.id && !formId.trim() && !isEditing) {
|
||||
const uniqueId = ensureUniqueId(result.id);
|
||||
setFormId(uniqueId);
|
||||
|
||||
// 如果 name 也为空,同时填充 name
|
||||
if (!formName.trim()) {
|
||||
setFormName(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 不在输入时自动格式化,保持用户输入的原样
|
||||
// 格式清理将在提交时进行
|
||||
|
||||
setConfigError("");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || String(err);
|
||||
@@ -283,30 +256,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
if (!formConfig.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(formConfig);
|
||||
setFormConfig(formatted);
|
||||
toast.success(t("common.formatSuccess"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWizardApply = (title: string, json: string) => {
|
||||
setFormId(title);
|
||||
if (!formName.trim()) {
|
||||
setFormName(title);
|
||||
}
|
||||
// Wizard returns JSON, convert based on format if needed
|
||||
if (useToml) {
|
||||
try {
|
||||
const server = JSON.parse(json) as McpServerSpec;
|
||||
@@ -329,17 +283,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 新增模式:阻止提交重名 ID
|
||||
if (!isEditing && existingIds.includes(trimmedId)) {
|
||||
setIdError(t("mcp.error.idExists"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate configuration format
|
||||
let serverSpec: McpServerSpec;
|
||||
|
||||
if (useToml) {
|
||||
// TOML mode
|
||||
const tomlError = validateTomlConfig(formConfig);
|
||||
setConfigError(tomlError);
|
||||
if (tomlError) {
|
||||
@@ -348,7 +299,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
|
||||
if (!formConfig.trim()) {
|
||||
// Empty configuration
|
||||
serverSpec = {
|
||||
type: "stdio",
|
||||
command: "",
|
||||
@@ -365,9 +315,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON mode
|
||||
if (!formConfig.trim()) {
|
||||
// Empty configuration
|
||||
serverSpec = {
|
||||
type: "stdio",
|
||||
command: "",
|
||||
@@ -375,7 +323,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
// 使用智能解析器,支持带外层键的格式
|
||||
const result = parseSmartMcpJson(formConfig);
|
||||
serverSpec = result.config as McpServerSpec;
|
||||
} catch (e: any) {
|
||||
@@ -387,7 +334,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 前置必填校验
|
||||
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
|
||||
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
||||
return;
|
||||
@@ -402,7 +348,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 先处理 name 字段(必填)
|
||||
const nameTrimmed = (formName || trimmedId).trim();
|
||||
const finalName = nameTrimmed || trimmedId;
|
||||
|
||||
@@ -411,7 +356,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
id: trimmedId,
|
||||
name: finalName,
|
||||
server: serverSpec,
|
||||
// 使用表单中的启用状态(v3.7.0 完整重构)
|
||||
apps: enabledApps,
|
||||
};
|
||||
|
||||
@@ -446,10 +390,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
delete entry.tags;
|
||||
}
|
||||
|
||||
// 保存到统一配置
|
||||
await upsertMutation.mutateAsync(entry);
|
||||
toast.success(t("common.success"));
|
||||
await onSave(); // 通知父组件关闭表单
|
||||
await onSave();
|
||||
} catch (error: any) {
|
||||
const detail = extractErrorMessage(error);
|
||||
const mapped = translateMcpBackendError(detail, t);
|
||||
@@ -466,18 +409,33 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getFormTitle()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
<FullScreenPanel
|
||||
isOpen={true}
|
||||
title={getFormTitle()}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || (!isEditing && !!idError)}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isEditing ? <Save size={16} /> : <Plus size={16} />}
|
||||
{saving
|
||||
? t("common.saving")
|
||||
: isEditing
|
||||
? t("common.save")
|
||||
: t("common.add")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col h-full gap-6">
|
||||
{/* 上半部分:表单字段 */}
|
||||
<div className="glass rounded-xl p-6 border border-white/10 space-y-6 flex-shrink-0">
|
||||
{/* 预设选择(仅新增时展示) */}
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
<label className="block text-sm font-medium text-foreground mb-3">
|
||||
{t("mcp.presets.title")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -487,7 +445,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedPreset === -1
|
||||
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
: "bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
}`}
|
||||
>
|
||||
{t("presetSelector.custom")}
|
||||
@@ -502,7 +460,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedPreset === idx
|
||||
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
: "bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
}`}
|
||||
title={t(descriptionKey)}
|
||||
>
|
||||
@@ -513,10 +471,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ID (标题) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{!isEditing && idError && (
|
||||
@@ -536,7 +495,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mcp.form.name")}
|
||||
</label>
|
||||
<Input
|
||||
@@ -547,9 +506,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 启用到哪些应用(v3.7.0 新增) */}
|
||||
{/* 启用到哪些应用 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<label className="block text-sm font-medium text-foreground mb-3">
|
||||
{t("mcp.form.enabledApps")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
@@ -563,7 +522,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-claude"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
@@ -579,7 +538,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-codex"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
@@ -595,7 +554,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-gemini"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
@@ -608,7 +567,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showMetadata ? (
|
||||
<ChevronUp size={16} />
|
||||
@@ -622,9 +581,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{/* 附加信息区域(可折叠) */}
|
||||
{showMetadata && (
|
||||
<>
|
||||
{/* Description (描述) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mcp.form.description")}
|
||||
</label>
|
||||
<Input
|
||||
@@ -635,9 +593,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mcp.form.tags")}
|
||||
</label>
|
||||
<Input
|
||||
@@ -648,9 +605,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Homepage */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mcp.form.homepage")}
|
||||
</label>
|
||||
<Input
|
||||
@@ -661,9 +617,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Docs */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mcp.form.docs")}
|
||||
</label>
|
||||
<Input
|
||||
@@ -675,79 +630,51 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 配置输入框(根据格式显示 JSON 或 TOML) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{useToml
|
||||
? t("mcp.form.tomlConfig")
|
||||
: t("mcp.form.jsonConfig")}
|
||||
</label>
|
||||
{(isEditing || selectedPreset === -1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWizardOpen(true)}
|
||||
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{t("mcp.form.useWizard")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
className="h-48 resize-none font-mono text-xs"
|
||||
placeholder={
|
||||
useToml
|
||||
? t("mcp.form.tomlPlaceholder")
|
||||
: t("mcp.form.jsonPlaceholder")
|
||||
}
|
||||
value={formConfig}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
/>
|
||||
{/* 格式化按钮(仅 JSON 模式) */}
|
||||
{!useToml && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormatJson}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format")}
|
||||
</button>
|
||||
</div>
|
||||
{/* 下半部分:JSON 配置编辑器 - 自适应剩余高度 */}
|
||||
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
|
||||
</label>
|
||||
{(isEditing || selectedPreset === -1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWizardOpen(true)}
|
||||
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{t("mcp.form.useWizard")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<JsonEditor
|
||||
value={formConfig}
|
||||
onChange={handleConfigChange}
|
||||
placeholder={
|
||||
useToml
|
||||
? t("mcp.form.tomlPlaceholder")
|
||||
: t("mcp.form.jsonPlaceholder")
|
||||
}
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
showValidation={!useToml}
|
||||
language={useToml ? "javascript" : "json"}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
{configError && (
|
||||
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
|
||||
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm flex-shrink-0">
|
||||
<AlertCircle size={16} />
|
||||
<span>{configError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex justify-end gap-3 pt-4">
|
||||
{/* 操作按钮 */}
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || (!isEditing && !!idError)}
|
||||
variant="mcp"
|
||||
>
|
||||
{isEditing ? <Save size={16} /> : <Plus size={16} />}
|
||||
{saving
|
||||
? t("common.saving")
|
||||
: isEditing
|
||||
? t("common.save")
|
||||
: t("common.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
|
||||
{/* Wizard Modal */}
|
||||
<McpWizardModal
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Server, Check } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
||||
import type { McpServer } from "@/types";
|
||||
@@ -22,7 +15,6 @@ import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UnifiedMcpPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -30,10 +22,14 @@ interface UnifiedMcpPanelProps {
|
||||
* 统一 MCP 管理面板
|
||||
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||
*/
|
||||
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
openAdd: () => void;
|
||||
}
|
||||
|
||||
const UnifiedMcpPanel = React.forwardRef<
|
||||
UnifiedMcpPanelHandle,
|
||||
UnifiedMcpPanelProps
|
||||
>(({ onOpenChange: _onOpenChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -90,6 +86,10 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAdd: handleAdd,
|
||||
}));
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
@@ -115,78 +115,50 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
|
||||
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||
<Plus size={16} />
|
||||
{t("mcp.unifiedPanel.addServer")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 px-6 py-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
||||
</div>
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{t("mcp.loading")}
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{t("mcp.loading")}
|
||||
</div>
|
||||
) : serverEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<Server
|
||||
size={24}
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("mcp.unifiedPanel.noServers")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("mcp.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{serverEntries.map(([id, server]) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
server={server}
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : serverEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<Server size={24} className="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("mcp.unifiedPanel.noServers")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("mcp.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="mcp"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Check size={16} />
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{serverEntries.map(([id, server]) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
server={server}
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Modal */}
|
||||
{isFormOpen && (
|
||||
@@ -215,9 +187,11 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
||||
|
||||
/**
|
||||
* 统一 MCP 列表项组件
|
||||
@@ -259,112 +233,110 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 左侧:服务器信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{name}
|
||||
</h3>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
title={t("mcp.presets.docs")}
|
||||
>
|
||||
{t("mcp.presets.docs")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{!description && tags && tags.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||
{tags.join(", ")}
|
||||
</p>
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:服务器信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{name}
|
||||
</h3>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
title={t("mcp.presets.docs")}
|
||||
>
|
||||
{t("mcp.presets.docs")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{!description && tags && tags.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||
{tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-claude`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-claude`}
|
||||
checked={server.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-codex`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-codex`}
|
||||
checked={server.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-gemini`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-gemini`}
|
||||
checked={server.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-claude`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-claude`}
|
||||
checked={server.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(id)}
|
||||
title={t("common.edit")}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-codex`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(id)}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-codex`}
|
||||
checked={server.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-gemini`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-gemini`}
|
||||
checked={server.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(id)}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(id)}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
153
src/components/prompts/PromptFormPanel.tsx
Normal 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;
|
||||
@@ -25,7 +25,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
const enabled = prompt.enabled === true;
|
||||
|
||||
return (
|
||||
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||
<div className="group relative h-16 rounded-xl border border-border-default bg-muted/50 p-4 transition-all duration-300 hover:bg-muted hover:border-border-default/80 hover:shadow-sm">
|
||||
<div className="flex items-center gap-4 h-full">
|
||||
{/* Toggle 开关 */}
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, FileText, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FileText } from "lucide-react";
|
||||
import { type AppId } from "@/lib/api";
|
||||
import { usePromptActions } from "@/hooks/usePromptActions";
|
||||
import PromptListItem from "./PromptListItem";
|
||||
import PromptFormModal from "./PromptFormModal";
|
||||
import PromptFormPanel from "./PromptFormPanel";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
interface PromptPanelProps {
|
||||
@@ -21,157 +13,143 @@ interface PromptPanelProps {
|
||||
appId: AppId;
|
||||
}
|
||||
|
||||
const PromptPanel: React.FC<PromptPanelProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
appId,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
titleKey: string;
|
||||
messageKey: string;
|
||||
messageParams?: Record<string, unknown>;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
export interface PromptPanelHandle {
|
||||
openAdd: () => void;
|
||||
}
|
||||
|
||||
const { prompts, loading, reload, savePrompt, deletePrompt, toggleEnabled } =
|
||||
usePromptActions(appId);
|
||||
const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
({ open, appId }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
titleKey: string;
|
||||
messageKey: string;
|
||||
messageParams?: Record<string, unknown>;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) reload();
|
||||
}, [open, reload]);
|
||||
const {
|
||||
prompts,
|
||||
loading,
|
||||
reload,
|
||||
savePrompt,
|
||||
deletePrompt,
|
||||
toggleEnabled,
|
||||
} = usePromptActions(appId);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingId(null);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (open) reload();
|
||||
}, [open, reload]);
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setEditingId(id);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
const handleAdd = () => {
|
||||
setEditingId(null);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const prompt = prompts[id];
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
titleKey: "prompts.confirm.deleteTitle",
|
||||
messageKey: "prompts.confirm.deleteMessage",
|
||||
messageParams: { name: prompt?.name },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deletePrompt(id);
|
||||
setConfirmDialog(null);
|
||||
} catch (e) {
|
||||
// Error handled by hook
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAdd: handleAdd,
|
||||
}));
|
||||
|
||||
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
|
||||
const handleEdit = (id: string) => {
|
||||
setEditingId(id);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||
const handleDelete = (id: string) => {
|
||||
const prompt = prompts[id];
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
titleKey: "prompts.confirm.deleteTitle",
|
||||
messageKey: "prompts.confirm.deleteMessage",
|
||||
messageParams: { name: prompt?.name },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deletePrompt(id);
|
||||
setConfirmDialog(null);
|
||||
} catch (e) {
|
||||
// Error handled by hook
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const appName = t(`apps.${appId}`);
|
||||
const panelTitle = t("prompts.title", { appName });
|
||||
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle>{panelTitle}</DialogTitle>
|
||||
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||
<Plus size={16} />
|
||||
{t("prompts.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||
|
||||
<div className="flex-shrink-0 px-6 py-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||
{enabledPrompt
|
||||
? t("prompts.enabledName", { name: enabledPrompt[1].name })
|
||||
: t("prompts.noneEnabled")}
|
||||
</div>
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||
{enabledPrompt
|
||||
? t("prompts.enabledName", { name: enabledPrompt[1].name })
|
||||
: t("prompts.noneEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{t("prompts.loading")}
|
||||
<div className="flex-1 overflow-y-auto pb-16">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : promptEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<FileText
|
||||
size={24}
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
) : promptEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<FileText
|
||||
size={24}
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("prompts.empty")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("prompts.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{promptEntries.map(([id, prompt]) => (
|
||||
<PromptListItem
|
||||
key={id}
|
||||
id={id}
|
||||
prompt={prompt}
|
||||
onToggle={toggleEnabled}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("prompts.empty")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("prompts.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{promptEntries.map(([id, prompt]) => (
|
||||
<PromptListItem
|
||||
key={id}
|
||||
id={id}
|
||||
prompt={prompt}
|
||||
onToggle={toggleEnabled}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="mcp"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Check size={16} />
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isFormOpen && (
|
||||
<PromptFormPanel
|
||||
appId={appId}
|
||||
editingId={editingId || undefined}
|
||||
initialData={editingId ? prompts[editingId] : undefined}
|
||||
onSave={savePrompt}
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFormOpen && (
|
||||
<PromptFormModal
|
||||
appId={appId}
|
||||
editingId={editingId || undefined}
|
||||
initialData={editingId ? prompts[editingId] : undefined}
|
||||
onSave={savePrompt}
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={t(confirmDialog.titleKey)}
|
||||
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={t(confirmDialog.titleKey)}
|
||||
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
PromptPanel.displayName = "PromptPanel";
|
||||
|
||||
export default PromptPanel;
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { Provider, CustomEndpoint } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import {
|
||||
@@ -48,6 +41,8 @@ export function AddProviderDialog({
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
icon: values.icon?.trim() || undefined,
|
||||
iconColor: values.iconColor?.trim() || undefined,
|
||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
@@ -58,8 +53,6 @@ export function AddProviderDialog({
|
||||
|
||||
if (!hasCustomEndpoints) {
|
||||
// 收集端点候选(仅在缺少自定义端点时兜底)
|
||||
// 1. 从预设配置中获取 endpointCandidates
|
||||
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
|
||||
const urlSet = new Set<string>();
|
||||
|
||||
const addUrl = (rawUrl?: string) => {
|
||||
@@ -170,34 +163,40 @@ export function AddProviderDialog({
|
||||
? t("provider.addCodexProvider")
|
||||
: t("provider.addGeminiProvider");
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{submitLabel}</DialogTitle>
|
||||
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form="provider-form">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={submitLabel}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={footer}
|
||||
>
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { Provider } from "@/types";
|
||||
import {
|
||||
ProviderForm,
|
||||
@@ -34,7 +27,7 @@ export function EditProviderDialog({
|
||||
}: EditProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是“当前生效供应商”,则尝试读取实时配置替换初始值
|
||||
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
|
||||
const [liveSettings, setLiveSettings] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -96,6 +89,8 @@ export function EditProviderDialog({
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
icon: values.icon?.trim() || undefined,
|
||||
iconColor: values.iconColor?.trim() || undefined,
|
||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||
// 保留或更新 meta 字段
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
@@ -112,45 +107,40 @@ export function EditProviderDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("provider.editProvider")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("provider.editProviderHint")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
providerId={provider.id}
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={{
|
||||
name: provider.name,
|
||||
notes: provider.notes,
|
||||
websiteUrl: provider.websiteUrl,
|
||||
// 若读取到实时配置则优先使用
|
||||
settingsConfig: initialSettingsConfig,
|
||||
category: provider.category,
|
||||
meta: provider.meta,
|
||||
}}
|
||||
showButtons={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form="provider-form">
|
||||
<Save className="h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={t("provider.editProvider")}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
providerId={provider.id}
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={{
|
||||
name: provider.name,
|
||||
notes: provider.notes,
|
||||
websiteUrl: provider.websiteUrl,
|
||||
// 若读取到实时配置则优先使用
|
||||
settingsConfig: initialSettingsConfig,
|
||||
category: provider.category,
|
||||
meta: provider.meta,
|
||||
icon: provider.icon,
|
||||
iconColor: provider.iconColor,
|
||||
}}
|
||||
showButtons={false}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { MoveVertical, Copy } from "lucide-react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DraggableAttributes,
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
|
||||
interface DragHandleProps {
|
||||
@@ -22,7 +22,6 @@ interface ProviderCardProps {
|
||||
provider: Provider;
|
||||
isCurrent: boolean;
|
||||
appId: AppId;
|
||||
isEditMode?: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
@@ -71,7 +70,6 @@ export function ProviderCard({
|
||||
provider,
|
||||
isCurrent,
|
||||
appId,
|
||||
isEditMode = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -116,53 +114,40 @@ export function ProviderCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg bg-card p-4 shadow-sm",
|
||||
"transition-[border-color,background-color,box-shadow,ring] duration-200",
|
||||
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||
isCurrent
|
||||
? "border border-border-default bg-primary/5 ring-2 ring-blue-500/30 dark:ring-blue-400/30"
|
||||
: "border border-border-default hover:border-border-hover",
|
||||
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
||||
: "hover:scale-[1.01]",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-active border-border-dragging shadow-lg",
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 overflow-hidden",
|
||||
"transition-[max-width,opacity] duration-200 ease-in-out",
|
||||
isEditMode ? "max-w-20 opacity-100" : "max-w-0 opacity-0",
|
||||
"-ml-1.5 flex-shrink-0 cursor-grab active:cursor-grabbing p-1.5",
|
||||
"text-muted-foreground/50 hover:text-muted-foreground transition-colors",
|
||||
dragHandleProps?.isDragging && "cursor-grabbing",
|
||||
)}
|
||||
aria-hidden={!isEditMode}
|
||||
aria-label={t("provider.dragHandle")}
|
||||
{...(dragHandleProps?.attributes ?? {})}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex-shrink-0 cursor-grab active:cursor-grabbing",
|
||||
dragHandleProps?.isDragging && "cursor-grabbing",
|
||||
)}
|
||||
aria-label={t("provider.dragHandle")}
|
||||
disabled={!isEditMode}
|
||||
{...(dragHandleProps?.attributes ?? {})}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<MoveVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => onDuplicate(provider)}
|
||||
disabled={!isEditMode}
|
||||
aria-label={t("provider.duplicate")}
|
||||
title={t("provider.duplicate")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 供应商图标 */}
|
||||
<div className="h-9 w-9 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
name={provider.name}
|
||||
color={provider.iconColor}
|
||||
size={26}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -210,23 +195,28 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<UsageFooter
|
||||
provider={provider}
|
||||
providerId={provider.id}
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
isCurrent={isCurrent}
|
||||
inline={true}
|
||||
/>
|
||||
<div className="relative flex items-center ml-auto">
|
||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]">
|
||||
<UsageFooter
|
||||
provider={provider}
|
||||
providerId={provider.id}
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
isCurrent={isCurrent}
|
||||
inline={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -7,6 +8,17 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { IconPicker } from "@/components/IconPicker";
|
||||
import { getIconMetadata } from "@/icons/extracted/metadata";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { ProviderFormData } from "@/lib/schemas/provider";
|
||||
|
||||
@@ -16,22 +28,115 @@ interface BasicFormFieldsProps {
|
||||
|
||||
export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
||||
|
||||
const currentIcon = form.watch("icon");
|
||||
const currentIconColor = form.watch("iconColor");
|
||||
const providerName = form.watch("name") || "Provider";
|
||||
const effectiveIconColor =
|
||||
currentIconColor ||
|
||||
(currentIcon ? getIconMetadata(currentIcon)?.defaultColor : undefined);
|
||||
|
||||
const handleIconSelect = (icon: string) => {
|
||||
const meta = getIconMetadata(icon);
|
||||
form.setValue("icon", icon);
|
||||
form.setValue("iconColor", meta?.defaultColor ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("provider.namePlaceholder")} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 图标选择区域 - 顶部居中,可选 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<Dialog open={iconDialogOpen} onOpenChange={setIconDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-20 h-20 p-3 rounded-xl border-2 border-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center"
|
||||
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={currentIcon}
|
||||
name={providerName}
|
||||
color={effectiveIconColor}
|
||||
size={48}
|
||||
/>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
variant="fullscreen"
|
||||
zIndex="top"
|
||||
overlayClassName="bg-[hsl(var(--background))] backdrop-blur-0"
|
||||
className="p-0 sm:rounded-none"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<p className="text-lg font-semibold leading-tight">
|
||||
{t("providerIcon.selectIcon", {
|
||||
defaultValue: "选择图标",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-6 mx-auto max-w-[56rem] px-6 py-6 w-full">
|
||||
<IconPicker
|
||||
value={currentIcon}
|
||||
onValueChange={handleIconSelect}
|
||||
color={effectiveIconColor}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t("common.done", { defaultValue: "完成" })}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 基础信息 - 网格布局 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("provider.namePlaceholder")} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.notes")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("provider.notesPlaceholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -46,20 +151,6 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.notes")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -21,23 +19,27 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,39 +52,19 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
{t("codexConfig.authJson")}
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
onChange={handleChange}
|
||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -116,6 +98,22 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -154,22 +152,14 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={onChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{configError && (
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
import { Save } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
@@ -38,44 +32,22 @@ export function CommonConfigEditor({
|
||||
onModalClose,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const handleFormatMain = () => {
|
||||
if (!value.trim()) return;
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
const handleFormatModal = () => {
|
||||
if (!commonConfigSnippet.trim()) return;
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(commonConfigSnippet);
|
||||
onCommonConfigSnippetChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -115,90 +87,30 @@ export function CommonConfigEditor({
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
id="settingsConfig"
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={14}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[16rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormatMain}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => !open && onModalClose()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>
|
||||
{t("claudeConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑通用配置片段",
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("claudeConfig.commonConfigHint", {
|
||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
<textarea
|
||||
value={commonConfigSnippet}
|
||||
onChange={(e) => onCommonConfigSnippetChange(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[14rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormatModal}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FullScreenPanel
|
||||
isOpen={isModalOpen}
|
||||
title={t("claudeConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑通用配置片段",
|
||||
})}
|
||||
onClose={onModalClose}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -206,9 +118,35 @@ export function CommonConfigEditor({
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("claudeConfig.commonConfigHint", {
|
||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,7 @@ import type { AppId } from "@/lib/api";
|
||||
import { vscodeApi } from "@/lib/api/vscode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||
|
||||
// 端点测速超时配置(秒)
|
||||
@@ -431,211 +425,218 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
onClose();
|
||||
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
zIndex="nested"
|
||||
className="max-w-2xl max-h-[80vh] flex flex-col p-0"
|
||||
if (!visible) return null;
|
||||
|
||||
const footer = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>{t("endpointTest.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{/* 测速控制栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{entries.length} {t("endpointTest.endpoints")}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-border-default "
|
||||
/>
|
||||
{t("endpointTest.autoSelect")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={runSpeedTest}
|
||||
disabled={isTesting || !hasEndpoints}
|
||||
size="sm"
|
||||
className="h-7 w-20 gap-1.5 text-xs"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("endpointTest.testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t("endpointTest.testSpeed")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={visible}
|
||||
title={t("endpointTest.title")}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col gap-6">
|
||||
{/* 测速控制栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{entries.length} {t("endpointTest.endpoints")}
|
||||
</div>
|
||||
|
||||
{/* 添加输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||
onChange={(event) => setCustomUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddEndpoint();
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddEndpoint}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
{t("endpointTest.autoSelect")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={runSpeedTest}
|
||||
disabled={isTesting || !hasEndpoints}
|
||||
size="sm"
|
||||
className="h-7 w-24 gap-1.5 text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("endpointTest.testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t("endpointTest.testSpeed")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 端点列表 */}
|
||||
{hasEndpoints ? (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isSelected = normalizedSelected === entry.url;
|
||||
const latency = entry.latency;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => handleSelect(entry.url)}
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
||||
: "border-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 选择指示器 */}
|
||||
<div
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
|
||||
isSelected
|
||||
? "bg-blue-500 dark:bg-blue-400"
|
||||
: "bg-gray-300 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||
{entry.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{latency !== null ? (
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`font-mono text-sm font-medium ${
|
||||
latency < 300
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: latency < 500
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: latency < 800
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{latency}ms
|
||||
</div>
|
||||
</div>
|
||||
) : isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : entry.error ? (
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("endpointTest.failed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">—</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveEndpoint(entry);
|
||||
}}
|
||||
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border-default bg-gray-50 py-8 text-center text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
{t("endpointTest.noEndpoints")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{lastError && (
|
||||
{/* 添加输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||
onChange={(event) => setCustomUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddEndpoint();
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddEndpoint}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{lastError}
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 端点列表 */}
|
||||
{hasEndpoints ? (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isSelected = normalizedSelected === entry.url;
|
||||
const latency = entry.latency;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => handleSelect(entry.url)}
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/5 shadow-sm"
|
||||
: "border-border-default bg-background hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 选择指示器 */}
|
||||
<div
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
|
||||
isSelected
|
||||
? "bg-blue-500 dark:bg-blue-400"
|
||||
: "bg-gray-300 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||
{entry.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{latency !== null ? (
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`font-mono text-sm font-medium ${
|
||||
latency < 300
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: latency < 500
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: latency < 800
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{latency}ms
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{entry.status
|
||||
? t("endpointTest.status", { code: entry.status })
|
||||
: t("endpointTest.notTested")}
|
||||
</div>
|
||||
</div>
|
||||
) : isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : entry.error ? (
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("endpointTest.failed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">—</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemoveEndpoint(entry);
|
||||
}}
|
||||
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border-default bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("endpointTest.empty")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React from "react";
|
||||
import { Save, Wand2 } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface GeminiCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -28,86 +21,32 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
GeminiCommonConfigModalProps
|
||||
> = ({ isOpen, onClose, value, onChange, error }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
zIndex="nested"
|
||||
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>
|
||||
{t("geminiConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑 Gemini 通用配置片段",
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("geminiConfig.commonConfigHint", {
|
||||
defaultValue:
|
||||
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={`{
|
||||
"timeout": 30000,
|
||||
"maxRetries": 3,
|
||||
"customField": "value"
|
||||
}`}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("geminiConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑 Gemini 通用配置片段",
|
||||
})}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -115,8 +54,35 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("geminiConfig.commonConfigHint", {
|
||||
defaultValue:
|
||||
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"timeout": 30000,
|
||||
"maxRetries": 3,
|
||||
"customField": "value"
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface GeminiEnvSectionProps {
|
||||
value: string;
|
||||
@@ -21,27 +19,27 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
try {
|
||||
// 重新格式化 .env 内容
|
||||
const formatted = value
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.join("\n");
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,41 +52,21 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="geminiEnv"
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -124,25 +102,22 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -187,43 +162,22 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
id="geminiConfig"
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"timeout": 30000,
|
||||
"maxRetries": 3
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">
|
||||
{configError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
|
||||
{!configError && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -78,6 +78,8 @@ interface ProviderFormProps {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
category?: ProviderCategory;
|
||||
meta?: ProviderMeta;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
};
|
||||
showButtons?: boolean;
|
||||
}
|
||||
@@ -147,6 +149,8 @@ export function ProviderForm({
|
||||
: appId === "gemini"
|
||||
? GEMINI_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
icon: initialData?.icon ?? "",
|
||||
iconColor: initialData?.iconColor ?? "",
|
||||
}),
|
||||
[initialData, appId],
|
||||
);
|
||||
@@ -651,7 +655,7 @@ export function ProviderForm({
|
||||
<form
|
||||
id="provider-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
className="space-y-6 glass rounded-xl p-6 border border-white/10"
|
||||
>
|
||||
{/* 预设供应商选择(仅新增模式显示) */}
|
||||
{!initialData && (
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ProviderPresetSelector({
|
||||
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
|
||||
};
|
||||
|
||||
// 获取预设按钮的内联样式(用于自定义背景色)
|
||||
@@ -128,7 +128,7 @@ export function ProviderPresetSelector({
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedPresetId === "custom"
|
||||
? "bg-blue-500 text-white dark:bg-blue-600"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
: "bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
}`}
|
||||
>
|
||||
{t("providerPreset.custom")}
|
||||
|
||||
@@ -44,66 +44,73 @@ export function ImportExportSection({
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.importExport")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<header className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("settings.importExport")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.importExportHint")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border-default p-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("settings.exportConfig")}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
|
||||
{/* Import and Export Buttons Side by Side */}
|
||||
<div className="grid grid-cols-2 gap-4 items-stretch">
|
||||
{/* Import Button */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1 min-w-[180px]"
|
||||
onClick={onSelectFile}
|
||||
className={`w-full h-auto py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white ${selectedFile && !isImporting ? "flex-col items-start" : "items-center"}`}
|
||||
onClick={!selectedFile ? onSelectFile : onImport}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t("settings.selectConfigFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedFile || isImporting}
|
||||
onClick={onImport}
|
||||
>
|
||||
{isImporting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.importing")}
|
||||
<div className="flex items-center gap-2 w-full justify-center">
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
) : selectedFile ? (
|
||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<FolderOpen className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{isImporting
|
||||
? t("settings.importing")
|
||||
: selectedFile
|
||||
? t("settings.import")
|
||||
: t("settings.selectConfigFile")}
|
||||
</span>
|
||||
) : (
|
||||
t("settings.import")
|
||||
</div>
|
||||
{selectedFile && !isImporting && (
|
||||
<div className="mt-2 w-full text-left">
|
||||
<p className="text-xs font-mono text-white/80 truncate">
|
||||
📄 {selectedFileName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{selectedFile ? (
|
||||
<Button type="button" variant="ghost" onClick={onClear}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFile ? (
|
||||
<p className="truncate rounded-md bg-muted/40 px-3 py-2 text-xs font-mono text-muted-foreground">
|
||||
{selectedFileName}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.noFileSelected")}
|
||||
</p>
|
||||
)}
|
||||
{/* Export Button */}
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-full py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white items-center"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("settings.exportConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportStatusMessage
|
||||
@@ -134,15 +141,19 @@ function ImportStatusMessage({
|
||||
}
|
||||
|
||||
const baseClass =
|
||||
"flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed";
|
||||
"flex items-start gap-3 rounded-xl border p-4 text-sm leading-relaxed backdrop-blur-sm";
|
||||
|
||||
if (status === "importing") {
|
||||
return (
|
||||
<div className={`${baseClass} border-border-default bg-muted/40`}>
|
||||
<Loader2 className="mt-0.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<div
|
||||
className={`${baseClass} border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400`}
|
||||
>
|
||||
<Loader2 className="mt-0.5 h-5 w-5 flex-shrink-0 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium">{t("settings.importing")}</p>
|
||||
<p className="text-muted-foreground">{t("common.loading")}</p>
|
||||
<p className="font-semibold">{t("settings.importing")}</p>
|
||||
<p className="text-blue-600/80 dark:text-blue-400/80">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -151,17 +162,19 @@ function ImportStatusMessage({
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass} border-green-200 bg-green-100/70 text-green-700`}
|
||||
className={`${baseClass} border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400`}
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("settings.importSuccess")}</p>
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-semibold">{t("settings.importSuccess")}</p>
|
||||
{backupId ? (
|
||||
<p className="text-xs">
|
||||
<p className="text-xs text-green-600/80 dark:text-green-400/80">
|
||||
{t("settings.backupId")}: {backupId}
|
||||
</p>
|
||||
) : null}
|
||||
<p>{t("settings.autoReload")}</p>
|
||||
<p className="text-green-600/80 dark:text-green-400/80">
|
||||
{t("settings.autoReload")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -170,12 +183,14 @@ function ImportStatusMessage({
|
||||
if (status === "partial-success") {
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass} border-yellow-200 bg-yellow-100/70 text-yellow-700`}
|
||||
className={`${baseClass} border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("settings.importPartialSuccess")}</p>
|
||||
<p>{t("settings.importPartialHint")}</p>
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-semibold">{t("settings.importPartialSuccess")}</p>
|
||||
<p className="text-yellow-600/80 dark:text-yellow-400/80">
|
||||
{t("settings.importPartialHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -184,11 +199,13 @@ function ImportStatusMessage({
|
||||
const message = errorMessage || t("settings.importFailed");
|
||||
|
||||
return (
|
||||
<div className={`${baseClass} border-red-200 bg-red-100/70 text-red-600`}>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("settings.importFailed")}</p>
|
||||
<p>{message}</p>
|
||||
<div
|
||||
className={`${baseClass} border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-semibold">{t("settings.importFailed")}</p>
|
||||
<p className="text-red-600/80 dark:text-red-400/80">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { LanguageSettings } from "@/components/settings/LanguageSettings";
|
||||
import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
||||
import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImportSuccess?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImportSuccess,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
settings,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isPortable,
|
||||
appConfigDir,
|
||||
resolvedDirs,
|
||||
updateSettings,
|
||||
updateDirectory,
|
||||
updateAppConfigDir,
|
||||
browseDirectory,
|
||||
browseAppConfigDir,
|
||||
resetDirectory,
|
||||
resetAppConfigDir,
|
||||
saveSettings,
|
||||
resetSettings,
|
||||
requiresRestart,
|
||||
acknowledgeRestart,
|
||||
} = useSettings();
|
||||
|
||||
const {
|
||||
selectedFile,
|
||||
status: importStatus,
|
||||
errorMessage,
|
||||
backupId,
|
||||
isImporting,
|
||||
selectImportFile,
|
||||
importConfig,
|
||||
exportConfig,
|
||||
clearSelection,
|
||||
resetStatus,
|
||||
} = useImportExport({ onImportSuccess });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("general");
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab("general");
|
||||
resetStatus();
|
||||
}
|
||||
}, [open, resetStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
}
|
||||
}, [requiresRestart]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
// 取消/直接关闭:恢复到初始设置(包括语言回滚)
|
||||
resetSettings();
|
||||
acknowledgeRestart();
|
||||
clearSelection();
|
||||
resetStatus();
|
||||
onOpenChange(false);
|
||||
}, [
|
||||
acknowledgeRestart,
|
||||
clearSelection,
|
||||
onOpenChange,
|
||||
resetSettings,
|
||||
resetStatus,
|
||||
]);
|
||||
|
||||
const closeAfterSave = useCallback(() => {
|
||||
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
|
||||
acknowledgeRestart();
|
||||
clearSelection();
|
||||
resetStatus();
|
||||
onOpenChange(false);
|
||||
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
|
||||
|
||||
const handleDialogChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
closeDialog();
|
||||
} else {
|
||||
onOpenChange(true);
|
||||
}
|
||||
},
|
||||
[closeDialog, onOpenChange],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
closeDialog();
|
||||
}, [closeDialog]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const result = await saveSettings();
|
||||
if (!result) return;
|
||||
if (result.requiresRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
return;
|
||||
}
|
||||
closeAfterSave();
|
||||
} catch (error) {
|
||||
console.error("[SettingsDialog] Failed to save settings", error);
|
||||
}
|
||||
}, [closeDialog, saveSettings]);
|
||||
|
||||
const handleRestartLater = useCallback(() => {
|
||||
setShowRestartPrompt(false);
|
||||
closeAfterSave();
|
||||
}, [closeAfterSave]);
|
||||
|
||||
const handleRestartNow = useCallback(async () => {
|
||||
setShowRestartPrompt(false);
|
||||
if (import.meta.env.DEV) {
|
||||
toast.success(t("settings.devModeRestartHint"));
|
||||
closeAfterSave();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsApi.restart();
|
||||
} catch (error) {
|
||||
console.error("[SettingsDialog] Failed to restart app", error);
|
||||
toast.error(t("settings.restartFailed"));
|
||||
} finally {
|
||||
closeAfterSave();
|
||||
}
|
||||
}, [closeAfterSave, t]);
|
||||
|
||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isBusy ? (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="general"
|
||||
className="space-y-6 mt-6 min-h-[400px]"
|
||||
>
|
||||
{settings ? (
|
||||
<>
|
||||
<LanguageSettings
|
||||
value={settings.language}
|
||||
onChange={(lang) => updateSettings({ language: lang })}
|
||||
/>
|
||||
<ThemeSettings />
|
||||
<WindowSettings
|
||||
settings={settings}
|
||||
onChange={updateSettings}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="advanced"
|
||||
className="space-y-6 mt-6 min-h-[400px]"
|
||||
>
|
||||
{settings ? (
|
||||
<>
|
||||
<DirectorySettings
|
||||
appConfigDir={appConfigDir}
|
||||
resolvedDirs={resolvedDirs}
|
||||
onAppConfigChange={updateAppConfigDir}
|
||||
onBrowseAppConfig={browseAppConfigDir}
|
||||
onResetAppConfig={resetAppConfigDir}
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
/>
|
||||
<ImportExportSection
|
||||
status={importStatus}
|
||||
selectedFile={selectedFile}
|
||||
errorMessage={errorMessage}
|
||||
backupId={backupId}
|
||||
isImporting={isImporting}
|
||||
onSelectFile={selectImportFile}
|
||||
onImport={importConfig}
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" className="mt-6 min-h-[400px]">
|
||||
<AboutSection isPortable={isPortable} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving || isBusy}>
|
||||
{isSaving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.saving")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<Dialog
|
||||
open={showRestartPrompt}
|
||||
onOpenChange={(open) => !open && handleRestartLater()}
|
||||
>
|
||||
<DialogContent zIndex="alert" className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.restartRequiredMessage")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleRestartLater}>
|
||||
{t("settings.restartLater")}
|
||||
</Button>
|
||||
<Button onClick={handleRestartNow}>
|
||||
{t("settings.restartNow")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
284
src/components/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { LanguageSettings } from "@/components/settings/LanguageSettings";
|
||||
import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
||||
import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImportSuccess?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImportSuccess,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
settings,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isPortable,
|
||||
appConfigDir,
|
||||
resolvedDirs,
|
||||
updateSettings,
|
||||
updateDirectory,
|
||||
updateAppConfigDir,
|
||||
browseDirectory,
|
||||
browseAppConfigDir,
|
||||
resetDirectory,
|
||||
resetAppConfigDir,
|
||||
saveSettings,
|
||||
autoSaveSettings,
|
||||
requiresRestart,
|
||||
acknowledgeRestart,
|
||||
} = useSettings();
|
||||
|
||||
const {
|
||||
selectedFile,
|
||||
status: importStatus,
|
||||
errorMessage,
|
||||
backupId,
|
||||
isImporting,
|
||||
selectImportFile,
|
||||
importConfig,
|
||||
exportConfig,
|
||||
clearSelection,
|
||||
resetStatus,
|
||||
} = useImportExport({ onImportSuccess });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("general");
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab("general");
|
||||
resetStatus();
|
||||
}
|
||||
}, [open, resetStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
}
|
||||
}, [requiresRestart]);
|
||||
|
||||
const closeAfterSave = useCallback(() => {
|
||||
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
|
||||
acknowledgeRestart();
|
||||
clearSelection();
|
||||
resetStatus();
|
||||
onOpenChange(false);
|
||||
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const result = await saveSettings(undefined, { silent: false });
|
||||
if (!result) return;
|
||||
if (result.requiresRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
return;
|
||||
}
|
||||
closeAfterSave();
|
||||
} catch (error) {
|
||||
console.error("[SettingsPage] Failed to save settings", error);
|
||||
}
|
||||
}, [closeAfterSave, saveSettings]);
|
||||
|
||||
const handleRestartLater = useCallback(() => {
|
||||
setShowRestartPrompt(false);
|
||||
closeAfterSave();
|
||||
}, [closeAfterSave]);
|
||||
|
||||
const handleRestartNow = useCallback(async () => {
|
||||
setShowRestartPrompt(false);
|
||||
if (import.meta.env.DEV) {
|
||||
toast.success(t("settings.devModeRestartHint"));
|
||||
closeAfterSave();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsApi.restart();
|
||||
} catch (error) {
|
||||
console.error("[SettingsPage] Failed to restart app", error);
|
||||
toast.error(t("settings.restartFailed"));
|
||||
} finally {
|
||||
closeAfterSave();
|
||||
}
|
||||
}, [closeAfterSave, t]);
|
||||
|
||||
// 通用设置即时保存(无需手动点击)
|
||||
// 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等)
|
||||
const handleAutoSave = useCallback(
|
||||
async (updates: Partial<SettingsFormState>) => {
|
||||
if (!settings) return;
|
||||
updateSettings(updates);
|
||||
try {
|
||||
await autoSaveSettings(updates);
|
||||
} catch (error) {
|
||||
console.error("[SettingsPage] Failed to autosave settings", error);
|
||||
toast.error(
|
||||
t("settings.saveFailedGeneric", {
|
||||
defaultValue: "保存失败,请重试",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[autoSaveSettings, settings, t, updateSettings],
|
||||
);
|
||||
|
||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||
{isBusy ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-xl">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
<TabsContent value="general" className="space-y-6 mt-0">
|
||||
{settings ? (
|
||||
<>
|
||||
<LanguageSettings
|
||||
value={settings.language}
|
||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||
/>
|
||||
<ThemeSettings />
|
||||
<WindowSettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
|
||||
{settings ? (
|
||||
<>
|
||||
<DirectorySettings
|
||||
appConfigDir={appConfigDir}
|
||||
resolvedDirs={resolvedDirs}
|
||||
onAppConfigChange={updateAppConfigDir}
|
||||
onBrowseAppConfig={browseAppConfigDir}
|
||||
onResetAppConfig={resetAppConfigDir}
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
/>
|
||||
<ImportExportSection
|
||||
status={importStatus}
|
||||
selectedFile={selectedFile}
|
||||
errorMessage={errorMessage}
|
||||
backupId={backupId}
|
||||
isImporting={isImporting}
|
||||
onSelectFile={selectImportFile}
|
||||
onImport={importConfig}
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
<div className="pt-6 border-t border-gray-200 dark:border-white/10">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="w-full"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.saving")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" className="mt-0">
|
||||
<AboutSection isPortable={isPortable} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={showRestartPrompt}
|
||||
onOpenChange={(open) => !open && handleRestartLater()}
|
||||
>
|
||||
<DialogContent
|
||||
zIndex="alert"
|
||||
className="max-w-md glass border-white/10"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.restartRequiredMessage")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleRestartLater}
|
||||
className="hover:bg-white/5"
|
||||
>
|
||||
{t("settings.restartLater")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestartNow}
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{t("settings.restartNow")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ToggleRow
|
||||
title={t("settings.launchOnStartup")}
|
||||
description={t("settings.launchOnStartupDescription")}
|
||||
checked={!!settings.launchOnStartup}
|
||||
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title={t("settings.minimizeToTray")}
|
||||
description={t("settings.minimizeToTrayDescription")}
|
||||
|
||||
219
src/components/skills/RepoManagerPanel.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerPanelProps {
|
||||
repos: SkillRepo[];
|
||||
skills: Skill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RepoManagerPanel({
|
||||
repos,
|
||||
skills,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: RepoManagerPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [skillsPath, setSkillsPath] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const getSkillCount = (repo: SkillRepo) =>
|
||||
skills.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
|
||||
const parseRepoUrl = (
|
||||
url: string,
|
||||
): { owner: string; name: string } | null => {
|
||||
let cleaned = url.trim();
|
||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||
cleaned = cleaned.replace(/\.git$/, "");
|
||||
|
||||
const parts = cleaned.split("/");
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { owner: parts[0], name: parts[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError("");
|
||||
|
||||
const parsed = parseRepoUrl(repoUrl);
|
||||
if (!parsed) {
|
||||
setError(t("skills.repo.invalidUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAdd({
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
branch: branch || "main",
|
||||
enabled: true,
|
||||
skillsPath: skillsPath.trim() || undefined,
|
||||
});
|
||||
|
||||
setRepoUrl("");
|
||||
setBranch("");
|
||||
setSkillsPath("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = async (owner: string, name: string) => {
|
||||
try {
|
||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={true}
|
||||
title={t("skills.repo.title")}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
添加技能仓库
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="repo-url" className="text-foreground">
|
||||
{t("skills.repo.url")}
|
||||
</Label>
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder={t("skills.repo.urlPlaceholder")}
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="branch" className="text-foreground">
|
||||
{t("skills.repo.branch")}
|
||||
</Label>
|
||||
<Input
|
||||
id="branch"
|
||||
placeholder={t("skills.repo.branchPlaceholder")}
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="skills-path" className="text-foreground">
|
||||
{t("skills.repo.path")}
|
||||
</Label>
|
||||
<Input
|
||||
id="skills-path"
|
||||
placeholder={t("skills.repo.pathPlaceholder")}
|
||||
value={skillsPath}
|
||||
onChange={(e) => setSkillsPath(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("skills.repo.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仓库列表 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("skills.repo.list")}
|
||||
</h3>
|
||||
{repos.length === 0 ? (
|
||||
<div className="text-center py-12 glass rounded-xl border border-white/10">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("skills.repo.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={`${repo.owner}/${repo.name}`}
|
||||
className="flex items-center justify-between rounded-xl border border-white/10 bg-gray-900/40 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{repo.owner}/{repo.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||
{repo.skillsPath && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
{t("skills.repo.path")}: {repo.skillsPath}
|
||||
</>
|
||||
)}
|
||||
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||
{t("skills.repo.skillCount", {
|
||||
count: getSkillCount(repo),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||
title={t("common.view", { defaultValue: "查看" })}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onRemove(repo.owner, repo.name)}
|
||||
title={t("common.delete")}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,8 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
|
||||
<Card className="glass flex flex-col h-full border border-white/10 bg-gray-900/40 transition-all duration-300 hover:bg-gray-900/60 hover:border-white/20 hover:shadow-lg group relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -95,7 +96,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||
{skill.description || t("skills.noDescription")}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
|
||||
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
|
||||
{skill.readmeUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManager } from "./RepoManager";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
@@ -12,94 +12,104 @@ interface SkillsPageProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
export interface SkillsPageHandle {
|
||||
refresh: () => void;
|
||||
openRepoManager: () => void;
|
||||
}
|
||||
|
||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await skillsApi.getAll();
|
||||
setSkills(data);
|
||||
if (afterLoad) {
|
||||
afterLoad(data);
|
||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
({ onClose: _onClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
|
||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await skillsApi.getAll();
|
||||
setSkills(data);
|
||||
if (afterLoad) {
|
||||
afterLoad(data);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 传入 "skills.loadFailed" 作为标题
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.loadFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 8000,
|
||||
});
|
||||
|
||||
console.error("Load skills failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
};
|
||||
|
||||
// 传入 "skills.loadFailed" 作为标题
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.loadFailed",
|
||||
);
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await skillsApi.getRepos();
|
||||
setRepos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load repos:", error);
|
||||
}
|
||||
};
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 8000,
|
||||
});
|
||||
useEffect(() => {
|
||||
Promise.all([loadSkills(), loadRepos()]);
|
||||
}, []);
|
||||
|
||||
console.error("Load skills failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => loadSkills(),
|
||||
openRepoManager: () => setRepoManagerOpen(true),
|
||||
}));
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await skillsApi.getRepos();
|
||||
setRepos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load repos:", error);
|
||||
}
|
||||
};
|
||||
const handleInstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.install(directory);
|
||||
toast.success(t("skills.installSuccess", { name: directory }));
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadSkills(), loadRepos()]);
|
||||
}, []);
|
||||
// 使用错误解析器格式化错误,传入 "skills.installFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.installFailed",
|
||||
);
|
||||
|
||||
const handleInstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.install(directory);
|
||||
toast.success(t("skills.installSuccess", { name: directory }));
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000, // 延长显示时间让用户看清
|
||||
});
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.installFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.installFailed",
|
||||
);
|
||||
console.error("Install skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000, // 延长显示时间让用户看清
|
||||
});
|
||||
|
||||
// 打印到控制台方便调试
|
||||
console.error("Install skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.uninstall(directory);
|
||||
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const handleUninstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.uninstall(directory);
|
||||
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
@@ -108,132 +118,105 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
||||
"skills.uninstallFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000,
|
||||
});
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000,
|
||||
});
|
||||
|
||||
console.error("Uninstall skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
console.error("Uninstall skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = async (repo: SkillRepo) => {
|
||||
await skillsApi.addRepo(repo);
|
||||
const handleAddRepo = async (repo: SkillRepo) => {
|
||||
await skillsApi.addRepo(repo);
|
||||
|
||||
let repoSkillCount = 0;
|
||||
await Promise.all([
|
||||
loadRepos(),
|
||||
loadSkills((data) => {
|
||||
repoSkillCount = data.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
}),
|
||||
]);
|
||||
let repoSkillCount = 0;
|
||||
await Promise.all([
|
||||
loadRepos(),
|
||||
loadSkills((data) => {
|
||||
repoSkillCount = data.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
}),
|
||||
]);
|
||||
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
count: repoSkillCount,
|
||||
}),
|
||||
);
|
||||
};
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
count: repoSkillCount,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||
await skillsApi.removeRepo(owner, name);
|
||||
toast.success(t("skills.repo.removeSuccess", { owner, name }));
|
||||
await Promise.all([loadRepos(), loadSkills()]);
|
||||
};
|
||||
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||
await skillsApi.removeRepo(owner, name);
|
||||
toast.success(t("skills.repo.removeSuccess", { owner, name }));
|
||||
await Promise.all([loadRepos(), loadSkills()]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background">
|
||||
{/* 顶部操作栏(固定区域) */}
|
||||
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
|
||||
{t("skills.title")}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="mcp"
|
||||
size="sm"
|
||||
onClick={() => loadSkills()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{loading ? t("skills.refreshing") : t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="mcp"
|
||||
size="sm"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background/50">
|
||||
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
|
||||
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
|
||||
<div className="mx-auto max-w-[56rem] px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("skills.empty")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("skills.emptyDescription")}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
className="mt-3 text-sm font-normal"
|
||||
>
|
||||
{t("skills.addRepo")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("skills.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("skills.empty")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("skills.emptyDescription")}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
className="mt-3 text-sm font-normal"
|
||||
>
|
||||
{t("skills.addRepo")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 仓库管理面板 */}
|
||||
{repoManagerOpen && (
|
||||
<RepoManagerPanel
|
||||
repos={repos}
|
||||
skills={skills}
|
||||
onAdd={handleAddRepo}
|
||||
onRemove={handleRemoveRepo}
|
||||
onClose={() => setRepoManagerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
{/* 仓库管理对话框 */}
|
||||
<RepoManager
|
||||
open={repoManagerOpen}
|
||||
onOpenChange={setRepoManagerOpen}
|
||||
repos={repos}
|
||||
skills={skills}
|
||||
onAdd={handleAddRepo}
|
||||
onRemove={handleRemoveRepo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SkillsPage.displayName = "SkillsPage";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
@@ -14,13 +13,14 @@ const DialogClose = DialogPrimitive.Close;
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
zIndex?: "base" | "nested" | "alert";
|
||||
zIndex?: "base" | "nested" | "alert" | "top";
|
||||
}
|
||||
>(({ className, zIndex = "base", ...props }, ref) => {
|
||||
const zIndexMap = {
|
||||
base: "z-40",
|
||||
nested: "z-50",
|
||||
alert: "z-[60]",
|
||||
top: "z-[110]",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -40,40 +40,54 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
zIndex?: "base" | "nested" | "alert";
|
||||
zIndex?: "base" | "nested" | "alert" | "top";
|
||||
variant?: "default" | "fullscreen";
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, zIndex = "base", ...props }, ref) => {
|
||||
const zIndexMap = {
|
||||
base: "z-40",
|
||||
nested: "z-50",
|
||||
alert: "z-[60]",
|
||||
};
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
zIndex = "base",
|
||||
variant = "default",
|
||||
overlayClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const zIndexMap = {
|
||||
base: "z-40",
|
||||
nested: "z-50",
|
||||
alert: "z-[60]",
|
||||
top: "z-[110]",
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay zIndex={zIndex} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-white dark:bg-gray-900 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
zIndexMap[zIndex],
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
// 防止点击遮罩层关闭对话框
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
const variantClass = {
|
||||
default:
|
||||
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-background text-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
fullscreen:
|
||||
"fixed inset-0 flex flex-col w-screen h-screen translate-x-0 translate-y-0 bg-background text-foreground p-0 sm:rounded-none shadow-none",
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay zIndex={zIndex} className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(variantClass, zIndexMap[zIndex], className)}
|
||||
onInteractOutside={(e) => {
|
||||
// 防止点击遮罩层关闭对话框
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface ProviderPreset {
|
||||
endpointCandidates?: string[];
|
||||
// 新增:视觉主题配置
|
||||
theme?: PresetTheme;
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
@@ -56,6 +59,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
backgroundColor: "#D97757",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "anthropic",
|
||||
iconColor: "#D4915D",
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
|
||||
73
src/config/iconInference.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 根据供应商名称智能推断图标配置
|
||||
*/
|
||||
|
||||
const iconMappings = {
|
||||
// AI 服务商
|
||||
claude: { icon: "claude", iconColor: "#D4915D" },
|
||||
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
|
||||
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
|
||||
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||
glm: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||
qwen: { icon: "qwen", iconColor: "#FF6A00" },
|
||||
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||
kimi: { icon: "kimi", iconColor: "#6366F1" },
|
||||
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
|
||||
baidu: { icon: "baidu", iconColor: "#2932E1" },
|
||||
tencent: { icon: "tencent", iconColor: "#00A4FF" },
|
||||
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
|
||||
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
|
||||
google: { icon: "google", iconColor: "#4285F4" },
|
||||
meta: { icon: "meta", iconColor: "#0081FB" },
|
||||
mistral: { icon: "mistral", iconColor: "#FF7000" },
|
||||
cohere: { icon: "cohere", iconColor: "#39594D" },
|
||||
perplexity: { icon: "perplexity", iconColor: "#20808D" },
|
||||
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
|
||||
|
||||
// 云平台
|
||||
aws: { icon: "aws", iconColor: "#FF9900" },
|
||||
azure: { icon: "azure", iconColor: "#0078D4" },
|
||||
huawei: { icon: "huawei", iconColor: "#FF0000" },
|
||||
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据预设名称推断图标
|
||||
*/
|
||||
export function inferIconForPreset(presetName: string): {
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
} {
|
||||
const nameLower = presetName.toLowerCase();
|
||||
|
||||
// 精确匹配或模糊匹配
|
||||
for (const [key, config] of Object.entries(iconMappings)) {
|
||||
if (nameLower.includes(key)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为预设添加图标配置
|
||||
*/
|
||||
export function addIconsToPresets<
|
||||
T extends { name: string; icon?: string; iconColor?: string },
|
||||
>(presets: T[]): T[] {
|
||||
return presets.map((preset) => {
|
||||
// 如果已经配置了图标,则保留原配置
|
||||
if (preset.icon) {
|
||||
return preset;
|
||||
}
|
||||
|
||||
// 否则根据名称推断
|
||||
const inferred = inferIconForPreset(preset.name);
|
||||
return {
|
||||
...preset,
|
||||
...inferred,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,13 @@ export interface UseSettingsResult {
|
||||
browseAppConfigDir: () => Promise<void>;
|
||||
resetDirectory: (app: AppId) => Promise<void>;
|
||||
resetAppConfigDir: () => Promise<void>;
|
||||
saveSettings: () => Promise<SaveResult | null>;
|
||||
saveSettings: (
|
||||
overrides?: Partial<SettingsFormState>,
|
||||
options?: { silent?: boolean },
|
||||
) => Promise<SaveResult | null>;
|
||||
autoSaveSettings: (
|
||||
updates: Partial<SettingsFormState>,
|
||||
) => Promise<SaveResult | null>;
|
||||
resetSettings: () => void;
|
||||
acknowledgeRestart: () => void;
|
||||
}
|
||||
@@ -114,97 +120,220 @@ export function useSettings(): UseSettingsResult {
|
||||
setRequiresRestart,
|
||||
]);
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
|
||||
if (!settings) return null;
|
||||
try {
|
||||
const sanitizedAppDir = sanitizeDir(appConfigDir);
|
||||
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(settings.geminiConfigDir);
|
||||
const previousAppDir = initialAppConfigDir;
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
|
||||
const payload: Settings = {
|
||||
...settings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
language: settings.language,
|
||||
};
|
||||
|
||||
await saveMutation.mutateAsync(payload);
|
||||
|
||||
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
|
||||
// 即时保存设置(用于 General 标签页的实时更新)
|
||||
// 保存基础配置 + 独立的系统 API 调用(开机自启)
|
||||
const autoSaveSettings = useCallback(
|
||||
async (updates: Partial<SettingsFormState>): Promise<SaveResult | null> => {
|
||||
const mergedSettings = settings ? { ...settings, ...updates } : null;
|
||||
if (!mergedSettings) return null;
|
||||
|
||||
try {
|
||||
if (payload.enableClaudePluginIntegration) {
|
||||
await settingsApi.applyClaudePluginConfig({ official: false });
|
||||
} else {
|
||||
await settingsApi.applyClaudePluginConfig({ official: true });
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
// 保存到配置文件
|
||||
await saveMutation.mutateAsync(payload);
|
||||
|
||||
// 如果开机自启状态改变,调用系统 API
|
||||
if (
|
||||
payload.launchOnStartup !== undefined &&
|
||||
payload.launchOnStartup !== data?.launchOnStartup
|
||||
) {
|
||||
try {
|
||||
await settingsApi.setAutoLaunch(payload.launchOnStartup);
|
||||
} catch (error) {
|
||||
console.error("Failed to update auto-launch:", error);
|
||||
toast.error(
|
||||
t("settings.autoLaunchFailed", {
|
||||
defaultValue: "设置开机自启失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync Claude plugin config",
|
||||
error,
|
||||
);
|
||||
toast.error(
|
||||
t("notifications.syncClaudePluginFailed", {
|
||||
defaultValue: "同步 Claude 插件失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("language", payload.language as Language);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to persist language preference",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (!syncResult.ok) {
|
||||
// 持久化语言偏好
|
||||
try {
|
||||
if (typeof window !== "undefined" && updates.language) {
|
||||
window.localStorage.setItem("language", updates.language);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync current providers after directory change",
|
||||
syncResult.error,
|
||||
"[useSettings] Failed to persist language preference",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// 更新托盘菜单
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
return { requiresRestart: false };
|
||||
} catch (error) {
|
||||
console.error("[useSettings] Failed to auto-save settings", error);
|
||||
toast.error(
|
||||
t("notifications.settingsSaveFailed", {
|
||||
defaultValue: "保存设置失败: {{error}}",
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[data, saveMutation, settings, t],
|
||||
);
|
||||
|
||||
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
|
||||
setRequiresRestart(appDirChanged);
|
||||
// 完整保存设置(用于 Advanced 标签页的手动保存)
|
||||
// 包含所有系统 API 调用和完整的验证流程
|
||||
const saveSettings = useCallback(
|
||||
async (
|
||||
overrides?: Partial<SettingsFormState>,
|
||||
options?: { silent?: boolean },
|
||||
): Promise<SaveResult | null> => {
|
||||
const mergedSettings = settings ? { ...settings, ...overrides } : null;
|
||||
if (!mergedSettings) return null;
|
||||
try {
|
||||
const sanitizedAppDir = sanitizeDir(appConfigDir);
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const previousAppDir = initialAppConfigDir;
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
|
||||
return { requiresRestart: appDirChanged };
|
||||
} catch (error) {
|
||||
console.error("[useSettings] Failed to save settings", error);
|
||||
throw error;
|
||||
}
|
||||
}, [
|
||||
appConfigDir,
|
||||
data,
|
||||
initialAppConfigDir,
|
||||
saveMutation,
|
||||
settings,
|
||||
setRequiresRestart,
|
||||
t,
|
||||
]);
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
await saveMutation.mutateAsync(payload);
|
||||
|
||||
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
|
||||
|
||||
// 只在开机自启状态真正改变时调用系统 API
|
||||
if (
|
||||
payload.launchOnStartup !== undefined &&
|
||||
payload.launchOnStartup !== data?.launchOnStartup
|
||||
) {
|
||||
try {
|
||||
await settingsApi.setAutoLaunch(payload.launchOnStartup);
|
||||
} catch (error) {
|
||||
console.error("Failed to update auto-launch:", error);
|
||||
toast.error(
|
||||
t("settings.autoLaunchFailed", {
|
||||
defaultValue: "设置开机自启失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 只在 Claude 插件集成状态真正改变时调用系统 API
|
||||
if (
|
||||
payload.enableClaudePluginIntegration !== undefined &&
|
||||
payload.enableClaudePluginIntegration !==
|
||||
data?.enableClaudePluginIntegration
|
||||
) {
|
||||
try {
|
||||
if (payload.enableClaudePluginIntegration) {
|
||||
await settingsApi.applyClaudePluginConfig({ official: false });
|
||||
} else {
|
||||
await settingsApi.applyClaudePluginConfig({ official: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync Claude plugin config",
|
||||
error,
|
||||
);
|
||||
toast.error(
|
||||
t("notifications.syncClaudePluginFailed", {
|
||||
defaultValue: "同步 Claude 插件失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
"language",
|
||||
payload.language as Language,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to persist language preference",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (!syncResult.ok) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync current providers after directory change",
|
||||
syncResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
|
||||
setRequiresRestart(appDirChanged);
|
||||
|
||||
if (!options?.silent) {
|
||||
toast.success(
|
||||
t("notifications.settingsSaved", {
|
||||
defaultValue: "设置已保存",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { requiresRestart: appDirChanged };
|
||||
} catch (error) {
|
||||
console.error("[useSettings] Failed to save settings", error);
|
||||
toast.error(
|
||||
t("notifications.settingsSaveFailed", {
|
||||
defaultValue: "保存设置失败: {{error}}",
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
appConfigDir,
|
||||
data,
|
||||
initialAppConfigDir,
|
||||
saveMutation,
|
||||
settings,
|
||||
setRequiresRestart,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => isFormLoading || isDirectoryLoading || isMetadataLoading,
|
||||
@@ -227,6 +356,7 @@ export function useSettings(): UseSettingsResult {
|
||||
resetDirectory,
|
||||
resetAppConfigDir,
|
||||
saveSettings,
|
||||
autoSaveSettings,
|
||||
resetSettings,
|
||||
acknowledgeRestart,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"formatSuccess": "Formatted successfully",
|
||||
"formatError": "Format failed: {{error}}",
|
||||
"copy": "Copy",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"back": "Back"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -166,6 +167,9 @@
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "Window Behavior",
|
||||
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
|
||||
"launchOnStartup": "Launch on Startup",
|
||||
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
|
||||
"autoLaunchFailed": "Failed to set auto-launch",
|
||||
"minimizeToTray": "Minimize to tray on close",
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
"enableClaudePluginIntegration": "Apply to Claude Code extension",
|
||||
@@ -314,7 +318,8 @@
|
||||
"pleaseAddEndpoint": "Please add an endpoint first",
|
||||
"testUnavailable": "Speed test unavailable",
|
||||
"noResult": "No result returned",
|
||||
"testFailed": "Speed test failed: {{error}}"
|
||||
"testFailed": "Speed test failed: {{error}}",
|
||||
"status": "Status: {{code}}"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -361,6 +366,9 @@
|
||||
"title": "Configure Usage Query",
|
||||
"enableUsageQuery": "Enable usage query",
|
||||
"presetTemplate": "Preset template",
|
||||
"requestUrl": "Request URL",
|
||||
"requestUrlPlaceholder": "e.g. https://api.example.com",
|
||||
"method": "HTTP method",
|
||||
"templateCustom": "Custom",
|
||||
"templateGeneral": "General",
|
||||
"templateNewAPI": "NewAPI",
|
||||
@@ -373,11 +381,14 @@
|
||||
"queryFailedMessage": "Query failed",
|
||||
"queryScript": "Query script (JavaScript)",
|
||||
"timeoutSeconds": "Timeout (seconds)",
|
||||
"headers": "Headers",
|
||||
"body": "Body",
|
||||
"timeoutHint": "Range: 2-30 seconds",
|
||||
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
|
||||
"timeoutCannotBeNegative": "Timeout cannot be negative",
|
||||
"autoIntervalMinutes": "Auto query interval (minutes)",
|
||||
"autoQueryInterval": "Auto Query Interval (minutes)",
|
||||
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
|
||||
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
|
||||
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
|
||||
"intervalCannotBeNegative": "Interval cannot be negative",
|
||||
"intervalAdjusted": "Interval adjusted to {{value}} minutes",
|
||||
@@ -398,6 +409,9 @@
|
||||
"formatSuccess": "Format successful",
|
||||
"formatFailed": "Format failed",
|
||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||
"scriptConfig": "Request configuration",
|
||||
"extractorCode": "Extractor code",
|
||||
"extractorHint": "Return object should include remaining quota fields",
|
||||
"fieldIsValid": "• isValid: Boolean, whether plan is valid",
|
||||
"fieldInvalidMessage": "• invalidMessage: String, reason for expiration (shown when isValid is false)",
|
||||
"fieldRemaining": "• remaining: Number, remaining quota",
|
||||
@@ -739,6 +753,29 @@
|
||||
"parseError": "Failed to parse deep link",
|
||||
"importSuccess": "Import successful",
|
||||
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
|
||||
"importError": "Failed to import"
|
||||
"importError": "Failed to import",
|
||||
"configSource": "Config Source",
|
||||
"configEmbedded": "Embedded Config",
|
||||
"configRemote": "Remote Config",
|
||||
"configDetails": "Config Details",
|
||||
"configUrl": "Config File URL",
|
||||
"configMergeError": "Failed to merge configuration file"
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "Search Icons",
|
||||
"searchPlaceholder": "Enter icon name...",
|
||||
"noResults": "No matching icons found",
|
||||
"category": {
|
||||
"aiProvider": "AI Providers",
|
||||
"cloud": "Cloud Platforms",
|
||||
"tool": "Dev Tools",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"providerIcon": {
|
||||
"label": "Icon",
|
||||
"colorLabel": "Icon Color",
|
||||
"selectIcon": "Select Icon",
|
||||
"preview": "Preview"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatError": "格式化失败:{{error}}",
|
||||
"copy": "复制",
|
||||
"view": "查看"
|
||||
"view": "查看",
|
||||
"back": "返回"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -166,6 +167,9 @@
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "窗口行为",
|
||||
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
|
||||
"launchOnStartup": "开机自启",
|
||||
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
|
||||
"autoLaunchFailed": "设置开机自启失败",
|
||||
"minimizeToTray": "关闭时最小化到托盘",
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
||||
@@ -314,7 +318,8 @@
|
||||
"pleaseAddEndpoint": "请先添加端点",
|
||||
"testUnavailable": "测速功能不可用",
|
||||
"noResult": "未返回结果",
|
||||
"testFailed": "测速失败: {{error}}"
|
||||
"testFailed": "测速失败: {{error}}",
|
||||
"status": "状态码:{{code}}"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -361,6 +366,9 @@
|
||||
"title": "配置用量查询",
|
||||
"enableUsageQuery": "启用用量查询",
|
||||
"presetTemplate": "预设模板",
|
||||
"requestUrl": "请求地址",
|
||||
"requestUrlPlaceholder": "例如:https://api.example.com",
|
||||
"method": "HTTP 方法",
|
||||
"templateCustom": "自定义",
|
||||
"templateGeneral": "通用模板",
|
||||
"templateNewAPI": "NewAPI",
|
||||
@@ -373,11 +381,14 @@
|
||||
"queryFailedMessage": "查询失败",
|
||||
"queryScript": "查询脚本(JavaScript)",
|
||||
"timeoutSeconds": "超时时间(秒)",
|
||||
"headers": "请求头",
|
||||
"body": "请求 Body",
|
||||
"timeoutHint": "范围: 2-30 秒",
|
||||
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
|
||||
"timeoutCannotBeNegative": "超时时间不能为负数",
|
||||
"autoIntervalMinutes": "自动查询间隔(分钟)",
|
||||
"autoQueryInterval": "自动查询间隔(分钟)",
|
||||
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
|
||||
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
|
||||
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
|
||||
"intervalCannotBeNegative": "自动查询间隔不能为负数",
|
||||
"intervalAdjusted": "自动查询间隔已调整为 {{value}} 分钟",
|
||||
@@ -398,6 +409,9 @@
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatFailed": "格式化失败",
|
||||
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
||||
"scriptConfig": "请求配置",
|
||||
"extractorCode": "提取器代码",
|
||||
"extractorHint": "返回对象需包含剩余额度等字段",
|
||||
"fieldIsValid": "• isValid: 布尔值,套餐是否有效",
|
||||
"fieldInvalidMessage": "• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)",
|
||||
"fieldRemaining": "• remaining: 数字,剩余额度",
|
||||
@@ -739,6 +753,29 @@
|
||||
"parseError": "深链接解析失败",
|
||||
"importSuccess": "导入成功",
|
||||
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
|
||||
"importError": "导入失败"
|
||||
"importError": "导入失败",
|
||||
"configSource": "配置来源",
|
||||
"configEmbedded": "内嵌配置",
|
||||
"configRemote": "远程配置",
|
||||
"configDetails": "配置详情",
|
||||
"configUrl": "配置文件 URL",
|
||||
"configMergeError": "合并配置文件失败"
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "搜索图标",
|
||||
"searchPlaceholder": "输入图标名称...",
|
||||
"noResults": "未找到匹配的图标",
|
||||
"category": {
|
||||
"aiProvider": "AI 服务商",
|
||||
"cloud": "云平台",
|
||||
"tool": "开发工具",
|
||||
"other": "其他"
|
||||
}
|
||||
},
|
||||
"providerIcon": {
|
||||
"label": "图标",
|
||||
"colorLabel": "图标颜色",
|
||||
"selectIcon": "选择图标",
|
||||
"preview": "预览"
|
||||
}
|
||||
}
|
||||
|
||||
1
src/icons/extracted/alibaba.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/icons/extracted/anthropic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
1
src/icons/extracted/aws.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
src/icons/extracted/azure.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
src/icons/extracted/baidu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/icons/extracted/bytedance.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ByteDance</title><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z" fill="#00C8D2" fill-rule="nonzero"></path><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z" fill="#3C8CFF" fill-rule="nonzero"></path><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z" fill="#78E6DC" fill-rule="nonzero"></path><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z" fill="#325AB4"></path></svg>
|
||||
|
After Width: | Height: | Size: 953 B |
1
src/icons/extracted/chatglm.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ChatGLM</title><defs><linearGradient id="lobe-icons-chat-glm-fill" x1="-18.756%" x2="70.894%" y1="49.371%" y2="90.944%"><stop offset="0%" stop-color="#504AF4"></stop><stop offset="100%" stop-color="#3485FF"></stop></linearGradient></defs><path d="M9.917 2c4.906 0 10.178 3.947 8.93 10.58-.014.07-.037.14-.057.21l-.003-.277c-.083-3-1.534-8.934-8.87-8.934-3.393 0-8.137 3.054-7.93 8.158-.04 4.778 3.555 8.4 7.95 8.332l.073-.001c1.2-.033 2.763-.429 3.1-1.657.063-.031.26.534.268.598.048.256.112.369.192.34.981-.348 2.286-1.222 1.952-2.38-.176-.61-1.775-.147-1.921-.347.418-.979 2.234-.926 3.153-.716.443.102.657.38 1.012.442.29.052.981-.2.96.242-1.5 3.042-4.893 5.41-8.808 5.41C3.654 22 0 16.574 0 11.737 0 5.947 4.959 2 9.917 2zM9.9 5.3c.484 0 1.125.225 1.38.585 3.669.145 4.313 2.686 4.694 5.444.255 1.838.315 2.3.182 1.387l.083.59c.068.448.554.737.982.516.144-.075.254-.231.328-.47a.2.2 0 01.258-.13l.625.22a.2.2 0 01.124.238 2.172 2.172 0 01-.51.92c-.878.917-2.757.664-3.08-.62-.14-.554-.055-.626-.345-1.242-.292-.621-1.238-.709-1.69-.295-.345.315-.407.805-.406 1.282L12.6 15.9a.9.9 0 01-.9.9h-1.4a.9.9 0 01-.9-.9v-.65a1.15 1.15 0 10-2.3 0v.65a.9.9 0 01-.9.9H4.8a.9.9 0 01-.9-.9l.035-3.239c.012-1.884.356-3.658 2.47-4.134.2-.045.252.13.29.342.025.154.043.252.053.294.701 3.058 1.75 4.299 3.144 3.722l.66-.331.254-.13c.158-.082.25-.131.276-.15.012-.01-.165-.206-.407-.464l-1.012-1.067a8.925 8.925 0 01-.199-.216c-.047-.034-.116.068-.208.306-.074.157-.251.252-.272.326-.013.058.108.298.362.72.164.288.22.508-.31.343-1.04-.8-1.518-2.273-1.684-3.725-.004-.035-.162-1.913-.162-1.913a1.2 1.2 0 011.113-1.281L9.9 5.3zm12.994 8.68c.037.697-.403.704-1.213.591l-1.783-.276c-.265-.053-.385-.099-.313-.147.47-.315 3.268-.93 3.31-.168zm-.915-.083l-.926.042c-.85.077-1.452.24.338.336l.103.003c.815.012 1.264-.359.485-.381zm1.667-3.601h.01c.79.398.067 1.03-.65 1.393-.14.07-.491.176-1.052.315-.241.04-.457.092-.333.16l.01.005c1.952.958-3.123 1.534-2.495 1.285l.38-.148c.68-.266 1.614-.682 1.666-1.337.038-.48 1.253-.442 1.493-.968.048-.106 0-.236-.144-.389-.05-.047-.094-.094-.107-.148-.073-.305.7-.431 1.222-.168zm-2.568-.474c-.135 1.198-2.479 4.192-1.949 2.863l.017-.042c.298-.717.376-2.221 1.337-3.221.25-.26.636.035.595.4zm-7.976-.253c.02-.694 1.002-.968 1.346-.347.01-1.274-1.941-.768-1.346.347z" fill="url(#lobe-icons-chat-glm-fill)" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
1
src/icons/extracted/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/icons/extracted/cloudflare.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437" fill="#F38020"></path><path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777" fill="#FCAD32"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/icons/extracted/cohere.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>
|
||||
|
After Width: | Height: | Size: 769 B |
1
src/icons/extracted/copilot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Copilot</title><path d="M17.533 1.829A2.528 2.528 0 0015.11 0h-.737a2.531 2.531 0 00-2.484 2.087l-1.263 6.937.314-1.08a2.528 2.528 0 012.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.528 2.528 0 01-2.423-1.829l-.715-2.453z" fill="url(#lobe-icons-copilot-fill-0)" transform="translate(0 1)"></path><path d="M6.726 20.16A2.528 2.528 0 009.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.528 2.528 0 01-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497c1.124 0 2.113.75 2.426 1.84l.697 2.432z" fill="url(#lobe-icons-copilot-fill-1)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-2)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-3)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-4)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-5)" transform="translate(0 1)"></path><defs><radialGradient cx="85.44%" cy="100.653%" fx="85.44%" fy="100.653%" gradientTransform="scale(-.8553 -1) rotate(50.927 2.041 -1.946)" id="lobe-icons-copilot-fill-0" r="105.116%"><stop offset="9.6%" stop-color="#00AEFF"></stop><stop offset="77.3%" stop-color="#2253CE"></stop><stop offset="100%" stop-color="#0736C4"></stop></radialGradient><radialGradient cx="18.143%" cy="32.928%" fx="18.143%" fy="32.928%" gradientTransform="scale(.8897 1) rotate(52.069 .193 .352)" id="lobe-icons-copilot-fill-1" r="95.612%"><stop offset="0%" stop-color="#FFB657"></stop><stop offset="63.4%" stop-color="#FF5F3D"></stop><stop offset="92.3%" stop-color="#C02B3C"></stop></radialGradient><radialGradient cx="82.987%" cy="-9.792%" fx="82.987%" fy="-9.792%" gradientTransform="scale(-1 -.9441) rotate(-70.872 .142 1.17)" id="lobe-icons-copilot-fill-4" r="140.622%"><stop offset="6.6%" stop-color="#8C48FF"></stop><stop offset="50%" stop-color="#F2598A"></stop><stop offset="89.6%" stop-color="#FFB152"></stop></radialGradient><linearGradient id="lobe-icons-copilot-fill-2" x1="39.465%" x2="46.884%" y1="12.117%" y2="103.774%"><stop offset="15.6%" stop-color="#0D91E1"></stop><stop offset="48.7%" stop-color="#52B471"></stop><stop offset="65.2%" stop-color="#98BD42"></stop><stop offset="93.7%" stop-color="#FFC800"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-3" x1="45.949%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#3DCBFF"></stop><stop offset="24.7%" stop-color="#0588F7" stop-opacity="0"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-5" x1="83.507%" x2="83.453%" y1="-6.106%" y2="21.131%"><stop offset="5.8%" stop-color="#F8ADFA"></stop><stop offset="70.8%" stop-color="#A86EDD" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
src/icons/extracted/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
src/icons/extracted/doubao.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
src/icons/extracted/gemini.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
src/icons/extracted/gemma.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><defs><linearGradient id="lobe-icons-gemma-fill" x1="24.419%" x2="75.194%" y1="75.581%" y2="25.194%"><stop offset="0%" stop-color="#446EFF"></stop><stop offset="36.661%" stop-color="#2E96FF"></stop><stop offset="83.221%" stop-color="#B1C5FF"></stop></linearGradient></defs><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z" fill="url(#lobe-icons-gemma-fill)" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
1
src/icons/extracted/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 907 B |
1
src/icons/extracted/githubcopilot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/icons/extracted/google.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>
|
||||
|
After Width: | Height: | Size: 920 B |
1
src/icons/extracted/googlecloud.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GoogleCloud</title><path d="M15.961 7.327l2.086-2.086.14-.879C14.384.905 8.34 1.297 4.913 5.18A9.643 9.643 0 002.88 8.991l.747-.105 4.172-.688.322-.33c1.856-2.038 4.994-2.312 7.137-.578l.703.037z" fill="#EA4335"></path><path d="M21.02 8.93a9.399 9.399 0 00-2.834-4.568L15.258 7.29a5.204 5.204 0 011.91 4.129v.52a2.606 2.606 0 012.607 2.605c0 1.44-1.167 2.577-2.606 2.577h-5.22l-.512.556v3.126l.513.49h5.219c3.743.03 6.802-2.952 6.83-6.695a6.778 6.778 0 00-2.98-5.668z" fill="#4285F4"></path><path d="M6.738 21.293h5.212v-4.172H6.738c-.371 0-.731-.08-1.069-.234l-.74.227-2.1 2.086-.183.71a6.763 6.763 0 004.092 1.383z" fill="#34A853"></path><path d="M6.738 7.759A6.778 6.778 0 002.646 19.91l3.023-3.023a2.606 2.606 0 113.448-3.448l3.023-3.023a6.771 6.771 0 00-5.402-2.657z" fill="#FBBC05"></path></svg>
|
||||
|
After Width: | Height: | Size: 925 B |
1
src/icons/extracted/grok.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
1
src/icons/extracted/huawei.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Huawei</title><path d="M10.341 17.042s.062-.061 0-.061C7.516 10.902 3.646 6.22 3.646 6.22S1.557 8.168 1.68 10.174c.061 1.52 1.228 2.37 1.228 2.37 1.843 1.763 6.266 4.012 7.31 4.499h.123zm-.737 1.52c0-.061-.123-.061-.123-.061l-7.371.243c.798 1.398 2.15 2.492 3.563 2.188.983-.243 3.194-1.763 3.87-2.25.123-.12.061-.12.061-.12zm.123-.67c.062-.06 0-.12 0-.12C6.471 15.581.206 12.3.206 12.3c-.553 1.763.184 3.161.184 3.161.798 1.702 2.334 2.189 2.334 2.189.676.303 1.413.303 1.413.303h5.529c.061 0 .061-.06.061-.06zm.492-14.831c-.308 0-1.168.243-1.168.243-1.965.486-2.395 2.249-2.395 2.249-.369 1.094 0 2.31 0 2.31.675 2.857 3.87 7.598 4.545 8.57l.062.062c.061 0 .061-.061.061-.061C12.43 5.796 10.22 3.06 10.22 3.06zm2.457 13.373c.061 0 .123-.061.123-.061.737-1.033 3.87-5.714 4.545-8.57 0 0 .369-1.399 0-2.31 0 0-.491-1.764-2.457-2.25 0 0-.553-.121-1.167-.243 0 0-2.211 2.796-1.106 13.312 0 .122.062.122.062.122zm1.72 2.067s-.062 0-.062.06v.122c.738.486 2.826 2.006 3.87 2.249 0 0 1.905.669 3.563-2.188l-7.371-.243zm9.398-6.261s-6.265 3.343-9.521 5.531c0 0-.062.06-.062.122 0 0 0 .06.062.06h5.651s.553 0 1.29-.303c0 0 1.536-.487 2.396-2.25 0-.06.737-1.458.184-3.16zM13.66 17.042s.061.06.122 0c1.045-.547 5.468-2.736 7.31-4.499 0 0 1.168-.911 1.23-2.37.122-2.067-1.967-3.951-1.967-3.951s-3.87 4.559-6.695 10.698c0 0-.062.06 0 .122z" fill="#C7000B"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/icons/extracted/huggingface.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>HuggingFace</title><path d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z" fill="#FF9D0B"></path><path d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z" fill="#FFD21E"></path><path d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z" fill="#FF323D"></path><path d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z" fill="#3A3B45"></path><path d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z" fill="#FF9D0B"></path><path d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z" fill="#FFD21E"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
1
src/icons/extracted/hunyuan.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
57
src/icons/extracted/index.ts
Normal file
1
src/icons/extracted/kimi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
1
src/icons/extracted/meta.svg
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
315
src/icons/extracted/metadata.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
// Icon metadata for search and categorization
|
||||
import { IconMetadata } from "@/types/icon";
|
||||
|
||||
export const iconMetadata: Record<string, IconMetadata> = {
|
||||
alibaba: {
|
||||
name: "alibaba",
|
||||
displayName: "Alibaba",
|
||||
category: "ai-provider",
|
||||
keywords: ["qwen", "tongyi"],
|
||||
defaultColor: "#FF6A00",
|
||||
},
|
||||
anthropic: {
|
||||
name: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
category: "ai-provider",
|
||||
keywords: ["claude"],
|
||||
defaultColor: "#D4915D",
|
||||
},
|
||||
aws: {
|
||||
name: "aws",
|
||||
displayName: "AWS",
|
||||
category: "cloud",
|
||||
keywords: ["amazon", "cloud"],
|
||||
defaultColor: "#FF9900",
|
||||
},
|
||||
azure: {
|
||||
name: "azure",
|
||||
displayName: "Azure",
|
||||
category: "cloud",
|
||||
keywords: ["microsoft", "cloud"],
|
||||
defaultColor: "#0078D4",
|
||||
},
|
||||
baidu: {
|
||||
name: "baidu",
|
||||
displayName: "Baidu",
|
||||
category: "ai-provider",
|
||||
keywords: ["ernie", "wenxin"],
|
||||
defaultColor: "#2932E1",
|
||||
},
|
||||
bytedance: {
|
||||
name: "bytedance",
|
||||
displayName: "bytedance",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
chatglm: {
|
||||
name: "chatglm",
|
||||
displayName: "chatglm",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
claude: {
|
||||
name: "claude",
|
||||
displayName: "Claude",
|
||||
category: "ai-provider",
|
||||
keywords: ["anthropic"],
|
||||
defaultColor: "#D4915D",
|
||||
},
|
||||
cloudflare: {
|
||||
name: "cloudflare",
|
||||
displayName: "Cloudflare",
|
||||
category: "cloud",
|
||||
keywords: ["cloudflare", "cdn"],
|
||||
defaultColor: "#F38020",
|
||||
},
|
||||
cohere: {
|
||||
name: "cohere",
|
||||
displayName: "Cohere",
|
||||
category: "ai-provider",
|
||||
keywords: ["cohere"],
|
||||
defaultColor: "#39594D",
|
||||
},
|
||||
copilot: {
|
||||
name: "copilot",
|
||||
displayName: "copilot",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
deepseek: {
|
||||
name: "deepseek",
|
||||
displayName: "DeepSeek",
|
||||
category: "ai-provider",
|
||||
keywords: ["deep", "seek"],
|
||||
defaultColor: "#1E88E5",
|
||||
},
|
||||
doubao: {
|
||||
name: "doubao",
|
||||
displayName: "doubao",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
gemini: {
|
||||
name: "gemini",
|
||||
displayName: "Gemini",
|
||||
category: "ai-provider",
|
||||
keywords: ["google"],
|
||||
defaultColor: "#4285F4",
|
||||
},
|
||||
gemma: {
|
||||
name: "gemma",
|
||||
displayName: "gemma",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
github: {
|
||||
name: "github",
|
||||
displayName: "GitHub",
|
||||
category: "tool",
|
||||
keywords: ["git", "version control"],
|
||||
defaultColor: "#181717",
|
||||
},
|
||||
githubcopilot: {
|
||||
name: "githubcopilot",
|
||||
displayName: "githubcopilot",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
google: {
|
||||
name: "google",
|
||||
displayName: "Google",
|
||||
category: "ai-provider",
|
||||
keywords: ["gemini", "bard"],
|
||||
defaultColor: "#4285F4",
|
||||
},
|
||||
googlecloud: {
|
||||
name: "googlecloud",
|
||||
displayName: "googlecloud",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
grok: {
|
||||
name: "grok",
|
||||
displayName: "grok",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
huawei: {
|
||||
name: "huawei",
|
||||
displayName: "Huawei",
|
||||
category: "cloud",
|
||||
keywords: ["huawei", "cloud"],
|
||||
defaultColor: "#FF0000",
|
||||
},
|
||||
huggingface: {
|
||||
name: "huggingface",
|
||||
displayName: "Hugging Face",
|
||||
category: "ai-provider",
|
||||
keywords: ["huggingface", "hf"],
|
||||
defaultColor: "#FFD21E",
|
||||
},
|
||||
hunyuan: {
|
||||
name: "hunyuan",
|
||||
displayName: "hunyuan",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
kimi: {
|
||||
name: "kimi",
|
||||
displayName: "Kimi",
|
||||
category: "ai-provider",
|
||||
keywords: ["moonshot"],
|
||||
defaultColor: "#6366F1",
|
||||
},
|
||||
meta: {
|
||||
name: "meta",
|
||||
displayName: "Meta",
|
||||
category: "ai-provider",
|
||||
keywords: ["facebook", "llama"],
|
||||
defaultColor: "#0081FB",
|
||||
},
|
||||
midjourney: {
|
||||
name: "midjourney",
|
||||
displayName: "midjourney",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
minimax: {
|
||||
name: "minimax",
|
||||
displayName: "MiniMax",
|
||||
category: "ai-provider",
|
||||
keywords: ["minimax"],
|
||||
defaultColor: "#FF6B6B",
|
||||
},
|
||||
mistral: {
|
||||
name: "mistral",
|
||||
displayName: "Mistral",
|
||||
category: "ai-provider",
|
||||
keywords: ["mistral"],
|
||||
defaultColor: "#FF7000",
|
||||
},
|
||||
notion: {
|
||||
name: "notion",
|
||||
displayName: "notion",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
ollama: {
|
||||
name: "ollama",
|
||||
displayName: "ollama",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
openai: {
|
||||
name: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "ai-provider",
|
||||
keywords: ["gpt", "chatgpt"],
|
||||
defaultColor: "#00A67E",
|
||||
},
|
||||
palm: {
|
||||
name: "palm",
|
||||
displayName: "palm",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
perplexity: {
|
||||
name: "perplexity",
|
||||
displayName: "Perplexity",
|
||||
category: "ai-provider",
|
||||
keywords: ["perplexity"],
|
||||
defaultColor: "#20808D",
|
||||
},
|
||||
qwen: {
|
||||
name: "qwen",
|
||||
displayName: "qwen",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
stability: {
|
||||
name: "stability",
|
||||
displayName: "stability",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
tencent: {
|
||||
name: "tencent",
|
||||
displayName: "Tencent",
|
||||
category: "ai-provider",
|
||||
keywords: ["hunyuan"],
|
||||
defaultColor: "#00A4FF",
|
||||
},
|
||||
vercel: {
|
||||
name: "vercel",
|
||||
displayName: "vercel",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
wenxin: {
|
||||
name: "wenxin",
|
||||
displayName: "wenxin",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
xai: {
|
||||
name: "xai",
|
||||
displayName: "xai",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
yi: {
|
||||
name: "yi",
|
||||
displayName: "yi",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
zeroone: {
|
||||
name: "zeroone",
|
||||
displayName: "zeroone",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
zhipu: {
|
||||
name: "zhipu",
|
||||
displayName: "Zhipu AI",
|
||||
category: "ai-provider",
|
||||
keywords: ["chatglm", "glm"],
|
||||
defaultColor: "#0F62FE",
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
1
src/icons/extracted/midjourney.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Midjourney</title><path d="M22.369 17.676c-1.387 1.259-3.17 2.378-5.332 3.417.044.03.086.057.13.083l.018.01.019.012c.216.123.42.184.641.184.222 0 .426-.061.642-.184l.018-.011.019-.011c.14-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.304-.174.612-.266.949-.266.337 0 .645.092.949.266l.023.014c.188.109.334.219.602.442l.178.148c.221.184.346.278.483.36l.028.017.018.01c.21.12.407.181.62.185h.022a.31.31 0 110 .618c-.337 0-.645-.092-.95-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.02-.014a5.356 5.356 0 01-.49-.377l-.159-.132a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.641.184l-.02.011-.018.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.95.266c-.337 0-.644-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.026-.017a4.881 4.881 0 01-.425-.325.308.308 0 01-.12-.1l-.098-.081a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.642.184l-.018.011-.019.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.023.014-.022.014-.09.054A1.868 1.868 0 0112 22c-.337 0-.645-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.021-.014a5.356 5.356 0 01-.49-.377l-.158-.132a3.836 3.836 0 00-.483-.36l-.028-.017-.018-.01a1.256 1.256 0 00-.642-.185c-.221 0-.425.061-.641.184l-.019.011-.018.011c-.141.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.511.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.986.264c-.746-.09-1.319-.38-1.89-.866l-.035-.03c-.047-.041-.118-.106-.192-.174l-.196-.181-.107-.1-.011-.01a1.531 1.531 0 00-.336-.253.313.313 0 00-.095-.03h-.005c-.119.022-.238.059-.361.11a.308.308 0 01-.077.061l-.008.005a.309.309 0 01-.126.034 5.66 5.66 0 00-.774.518l-.416.324-.055.043a6.542 6.542 0 01-.324.236c-.305.207-.552.315-.8.315a.31.31 0 01-.01-.618h.01c.09 0 .235-.062.438-.198l.04-.027c.077-.054.163-.117.27-.199l.385-.301.06-.047c.268-.206.506-.373.73-.505l-.633-1.21a.309.309 0 01.254-.451l20.287-1.305a.309.309 0 01.228.537zm-1.118.14L2.369 19.03l.423.809c.128-.045.256-.078.388-.1a.31.31 0 01.052-.005c.132 0 .26.032.386.093.153.073.294.179.483.35l.016.015.092.086.144.134.097.089c.065.06.125.114.16.144.485.418.948.658 1.554.736h.011a1.25 1.25 0 00.6-.172l.021-.011.019-.011.018-.011c.141-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.305-.174.612-.266.95-.266.336 0 .644.092.948.266l.023.014c.188.109.335.219.603.442l.177.148c.222.184.346.278.484.36l.027.017.019.01c.215.124.42.185.641.185.222 0 .426-.061.641-.184l.019-.011.018-.011c.141-.084.267-.178.493-.366l.177-.148c.28-.232.427-.342.626-.456.304-.174.612-.266.949-.266.337 0 .644.092.949.266l.025.015c.187.109.334.22.603.443 1.867-.878 3.448-1.811 4.73-2.832l.02-.016zM3.653 2.026C6.073 3.06 8.69 4.941 10.8 7.258c2.46 2.7 4.109 5.828 4.637 9.149a.31.31 0 01-.421.335c-2.348-.945-4.54-1.258-6.59-1.02-1.739.2-3.337.792-4.816 1.703-.294.182-.62-.182-.405-.454 1.856-2.355 2.581-4.99 2.343-7.794-.195-2.292-1.031-4.61-2.284-6.709a.31.31 0 01.388-.442zM10.04 4.45c1.778.543 3.892 2.102 5.782 4.243 1.984 2.248 3.552 4.934 4.347 7.582a.31.31 0 01-.401.38l-.022-.01-.386-.154a10.594 10.594 0 00-.291-.112l-.016-.006c-.68-.247-1.199-.291-1.944-.101a.31.31 0 01-.375-.218C15.378 11.123 13.073 7.276 9.775 5c-.291-.201-.072-.653.266-.55zM4.273 2.996l.008.015c1.028 1.94 1.708 4.031 1.885 6.113.213 2.513-.31 4.906-1.673 7.092l-.02.031.003-.001c1.198-.581 2.47-.969 3.825-1.132l.055-.006c1.981-.23 4.083.029 6.309.837l.066.025-.007-.039c-.593-2.95-2.108-5.737-4.31-8.179l-.07-.078c-1.785-1.96-3.944-3.6-6.014-4.65l-.057-.028zm7.92 3.238l.048.048c2.237 2.295 3.885 5.431 4.974 9.191l.038.132.022-.004c.71-.133 1.284-.063 1.963.18l.027.01.066.024.046.018-.025-.073c-.811-2.307-2.208-4.62-3.936-6.594l-.058-.065c-1.02-1.155-2.103-2.132-3.15-2.856l-.015-.011z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
src/icons/extracted/minimax.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/icons/extracted/mistral.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>
|
||||
|
After Width: | Height: | Size: 655 B |
1
src/icons/extracted/notion.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path clip-rule="evenodd" d="M15.257.055l-13.31.98C.874 1.128.5 1.83.5 2.667v14.559c0 .654.233 1.213.794 1.96l3.129 4.06c.513.653.98.794 1.962.745l15.457-.932c1.307-.093 1.681-.7 1.681-1.727V4.954c0-.53-.21-.684-.829-1.135l-.106-.078L18.34.755c-1.027-.746-1.45-.84-3.083-.7zm-8.521 4.63c-1.263.086-1.549.105-2.266-.477L2.647 2.76c-.186-.187-.092-.42.375-.466l12.796-.933c1.074-.094 1.634.28 2.054.606l2.195 1.587c.093.047.326.326.047.326l-13.216.794-.162.01zM5.263 21.193V7.287c0-.606.187-.886.748-.933l15.176-.886c.515-.047.748.28.748.886v13.81c0 .609-.093 1.122-.934 1.168l-14.523.84c-.842.047-1.215-.232-1.215-.98zm14.338-13.16c.093.422 0 .842-.422.89l-.699.139v10.264c-.608.327-1.168.513-1.635.513-.747 0-.934-.232-1.495-.932l-4.576-7.185v6.952l1.448.327s0 .84-1.169.84l-3.221.186c-.094-.187 0-.654.327-.747l.84-.232V9.853L7.832 9.76c-.093-.42.14-1.026.794-1.073l3.456-.232 4.763 7.279v-6.44l-1.214-.14c-.094-.513.28-.887.747-.933l3.223-.187z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
src/icons/extracted/ollama.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
src/icons/extracted/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/icons/extracted/palm.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>PaLM</title><path d="M12 22.926c.928 0 1.679-.752 1.679-1.68V6.696h-3.358v14.552c0 .927.751 1.679 1.679 1.679z" fill="#F9AB00"></path><path d="M18.69 12.005A5.819 5.819 0 0012 10.904l7.188 7.188c.296.296.807.179.933-.22a5.815 5.815 0 00-1.431-5.867z" fill="#5BB974"></path><path d="M5.31 12.005A5.819 5.819 0 0112 10.904l-7.188 7.188a.562.562 0 01-.933-.22 5.815 5.815 0 011.431-5.867z" fill="#129EAF"></path><path d="M18.157 6.426c-2.86 0-5.288 1.875-6.157 4.478h11.367a.629.629 0 00.565-.908c-1.08-2.12-3.26-3.57-5.775-3.57z" fill="#AF5CF7"></path><path d="M13.188 3.384c-2.023 2.024-2.414 5.064-1.188 7.52l8.038-8.039a.629.629 0 00-.242-1.042c-2.264-.735-4.83-.217-6.608 1.561z" fill="#FF8BCB"></path><path d="M10.812 3.384c2.023 2.024 2.414 5.064 1.188 7.52L3.962 2.865a.629.629 0 01.242-1.042c2.264-.735 4.83-.217 6.608 1.561z" fill="#FA7B17"></path><path d="M5.843 6.426c2.86 0 5.288 1.875 6.157 4.478H.633a.629.629 0 01-.565-.908c1.08-2.12 3.26-3.57 5.775-3.57z" fill="#4285F4"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |