From d30562954a17f6b341d1e6497b5bbc13b2e09694 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 24 Nov 2025 11:00:45 +0800 Subject: [PATCH] Refactor/storage (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` field for icon name - Add `icon_color: Option` 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 * feat(database): add SQLite database infrastructure - Add rusqlite dependency (v0.32.1) and r2d2 connection pooling - Implement Database module with CRUD operations for providers, MCP servers, prompts, and skills - Add schema initialization with proper indexes - Include data migration utilities from JSON config to SQLite - Support timestamp tracking (created_at, updated_at) * refactor(core): integrate SQLite database into application core - Initialize database on app startup with migration from JSON config - Update AppState to include Database instance alongside MultiAppConfig - Simplify store module by removing unused session management code - Add database initialization to app setup flow - Support both database and legacy config during transition * refactor(services): migrate service layer to use SQLite database - Refactor ProviderService to use database queries instead of in-memory config - Update McpService to fetch and store MCP servers in database - Migrate PromptService to database-backed storage - Simplify ConfigService by removing complex transaction logic - Remove 648 lines of redundant code through database abstraction * refactor(commands): update command layer to use database API - Update config commands to query database for providers and settings - Modify provider commands to pass database handle to services - Update MCP commands to use database-backed operations - Refactor prompt and skill commands to leverage database storage - Simplify import/export commands with database integration * refactor(backend): update supporting modules for database compatibility - Add DatabaseError variant to AppError enum - Update provider module to support database-backed operations - Modify codex_config to work with new database structure - Ensure error handling covers database operations * refactor(frontend): update UI components for database migration - Update UsageFooter component to handle new data structure - Modify SkillsPage to work with database-backed skills management - Ensure frontend compatibility with refactored backend * feat(skills): add search functionality to Skills page - Add search input with Search icon in SkillsPage component - Implement useMemo-based filtering by skill name, description, and directory - Display search results count when filtering is active - Show "no results" message when no skills match the search query - Add i18n translations for search UI (zh/en) - Maintain responsive layout and consistent styling with existing UI * refactor(backend): replace unsafe unwrap calls with proper error handling - Add to_json_string helper for safe JSON serialization - Add lock_conn macro for safe Mutex locking - Replace 41 unwrap() calls with proper error handling: - database.rs: JSON serialization and Mutex operations (31 fixes) - lib.rs: macOS NSWindow and tray icon handling (3 fixes) - services/provider.rs: Claude model normalization (1 fix) - services/prompt.rs: timestamp generation (3 fixes) - services/skill.rs: directory name extraction (2 fixes) - mcp.rs: HashMap initialization and type conversions (5 fixes) - app_config.rs: timestamp fallback (1 fix) This improves application stability and prevents potential panics. * feat(init): implement automatic data import on first launch Add comprehensive first-launch data import system: Database layer: - Add is_empty_for_first_import() to detect empty database - Add init_default_skill_repos() to initialize 3 default skill repositories Services layer: - Implement McpService::import_from_claude/codex/gemini() to import MCP servers from existing config files - Implement PromptService::import_from_file_on_first_launch() to import prompt files (CLAUDE.md, AGENTS.md, GEMINI.md) Startup flow (lib.rs): - Check if database is empty on startup - Import existing configurations if detected: 1. Initialize default skill repositories 2. Import provider configurations from live settings 3. Import MCP servers from config files 4. Import prompt files - All imports are fault-tolerant and logged This ensures seamless migration from file-based configs to database. * fix(skills): auto-sync locally installed skills to database Add automatic synchronization in get_skills command: - Detect locally installed skills in ~/.claude/skills/ - Auto-add to database if not already tracked - Ensures existing skills are recognized on first launch This fixes the issue where user's existing skills were not imported into the database on initial application run. * docs(frontend): add code comments and improve formatting - Add explanatory comment in EditProviderDialog about config assembly - Improve import formatting in SkillsPage for better readability * feat(deeplink): display all four Claude model fields in import dialog - Show haiku/sonnet/opus/multiModel fields conditionally for Claude - Maintain single model field display for Codex and Gemini - Add i18n translations for new model field labels (zh/en) * feat(backend): add database SQL export/import with backup - Enable rusqlite backup feature for SQL dump support - Implement export_sql to generate SQLite-compatible SQL dumps - Implement import_sql with automatic backup before import - Add snapshot_to_memory to avoid long-held database locks - Add backup rotation to retain latest 10 backups - Support atomic import with rollback on failure * refactor(backend): migrate import/export to use SQL backup - Reimplement export_config_to_file to use database.export_sql - Reimplement import_config_from_file to use database.import_sql - Add sync_current_from_db to sync live configs after import - Add settings database binding on app initialization - Remove deprecated JSON-based config import logic * refactor(backend): migrate settings storage to database - Add bind_db function to initialize database-backed settings - Implement load_initial_settings with database fallback - Replace direct file save with settings store management - Add SETTINGS_DB static for database binding - Maintain backward compatibility with file-based settings - Keep settings.json for legacy migration support * feat(frontend): update import/export UI for SQL backup - Change default export filename from .json to .sql - Update file format to timestamp format (YYYYMMDD_HHMMSS) - Update error messages to reference SQL files - Align with backend SQL export/import implementation * feat(i18n): update translations for SQL backup feature - Update Chinese translations for SQL import/export - Update English translations for SQL import/export - Change terminology from 'config file' to 'SQL backup' - Update error messages and UI hints * fix(backend): remove unnecessary dereference in backup operation - Simplify Backup::new call by removing redundant dereference - MutexGuard already implements DerefMut --- src-tauri/Cargo.lock | 71 +- src-tauri/Cargo.toml | 2 + src-tauri/src/app_config.rs | 7 +- src-tauri/src/codex_config.rs | 2 + src-tauri/src/commands/config.rs | 71 +- src-tauri/src/commands/import_export.rs | 76 +- src-tauri/src/commands/mcp.rs | 11 +- src-tauri/src/commands/prompt.rs | 4 +- src-tauri/src/commands/provider.rs | 4 +- src-tauri/src/commands/skill.rs | 108 +- src-tauri/src/config.rs | 2 + src-tauri/src/database.rs | 1225 +++++++++++++++++ src-tauri/src/error.rs | 2 + src-tauri/src/init_status.rs | 2 + src-tauri/src/lib.rs | 242 +++- src-tauri/src/mcp.rs | 33 +- src-tauri/src/provider.rs | 5 +- src-tauri/src/services/config.rs | 13 +- src-tauri/src/services/mcp.rs | 157 +-- src-tauri/src/services/prompt.rs | 137 +- src-tauri/src/services/provider.rs | 836 +++-------- src-tauri/src/services/skill.rs | 14 +- src-tauri/src/settings.rs | 96 +- src-tauri/src/store.rs | 22 +- src/components/DeepLinkImportDialog.tsx | 69 +- src/components/UsageFooter.tsx | 20 +- .../providers/EditProviderDialog.tsx | 2 + src/components/skills/SkillsPage.tsx | 95 +- src/hooks/useImportExport.ts | 12 +- src/i18n/locales/en.json | 21 +- src/i18n/locales/zh.json | 21 +- 31 files changed, 2247 insertions(+), 1135 deletions(-) create mode 100644 src-tauri/src/database.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a14e778f..4f7b9199 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -39,6 +39,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -614,6 +626,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "futures", + "indexmap 2.11.4", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -621,6 +634,7 @@ dependencies = [ "regex", "reqwest", "rquickjs", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -1275,6 +1289,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1813,7 +1839,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -1821,6 +1847,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] [[package]] name = "hashbrown" @@ -1828,6 +1857,15 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2398,6 +2436,17 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3849,6 +3898,20 @@ dependencies = [ "cc", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.9.4", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -5567,6 +5630,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 85ad685d..384802a5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,8 @@ url = "2.5" auto-launch = "0.5" once_cell = "1.21.3" base64 = "0.22" +rusqlite = { version = "0.31", features = ["bundled", "backup"] } +indexmap = { version = "2", features = ["serde"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 6343596f..2c596771 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -494,8 +494,11 @@ impl MultiAppConfig { // 创建提示词对象 let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + .map(|d| d.as_secs() as i64) + .unwrap_or_else(|_| { + log::warn!("Failed to get system time, using 0 as timestamp"); + 0 + }); let id = format!("auto-imported-{timestamp}"); let prompt = crate::prompt::Prompt { diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index c3a5e190..1d74b433 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -29,6 +29,7 @@ pub fn get_codex_config_path() -> PathBuf { } /// 获取 Codex 供应商配置文件路径 +#[allow(dead_code)] pub fn get_codex_provider_paths( provider_id: &str, provider_name: Option<&str>, @@ -44,6 +45,7 @@ pub fn get_codex_provider_paths( } /// 删除 Codex 供应商配置文件 +#[allow(dead_code)] pub fn delete_codex_provider_config( provider_id: &str, provider_name: &str, diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 2f81defd..29ee9c61 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -141,11 +141,10 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { pub async fn get_claude_common_config_snippet( state: tauri::State<'_, crate::store::AppState>, ) -> Result, String> { - let guard = state - .config - .read() - .map_err(|e| format!("读取配置锁失败: {e}"))?; - Ok(guard.common_config_snippets.claude.clone()) + state + .db + .get_config_snippet("claude") + .map_err(|e| e.to_string()) } /// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet) @@ -154,24 +153,22 @@ pub async fn set_claude_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - let mut guard = state - .config - .write() - .map_err(|e| format!("写入配置锁失败: {e}"))?; - // 验证是否为有效的 JSON(如果不为空) if !snippet.trim().is_empty() { serde_json::from_str::(&snippet) .map_err(|e| format!("无效的 JSON 格式: {e}"))?; } - guard.common_config_snippets.claude = if snippet.trim().is_empty() { + let value = if snippet.trim().is_empty() { None } else { Some(snippet) }; - guard.save().map_err(|e| e.to_string())?; + state + .db + .set_config_snippet("claude", value) + .map_err(|e| e.to_string())?; Ok(()) } @@ -181,17 +178,10 @@ pub async fn get_common_config_snippet( app_type: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result, String> { - use crate::app_config::AppType; - use std::str::FromStr; - - let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; - - let guard = state - .config - .read() - .map_err(|e| format!("读取配置锁失败: {e}"))?; - - Ok(guard.common_config_snippets.get(&app).cloned()) + state + .db + .get_config_snippet(&app_type) + .map_err(|e| e.to_string()) } /// 设置通用配置片段(统一接口) @@ -201,40 +191,31 @@ pub async fn set_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - use crate::app_config::AppType; - use std::str::FromStr; - - let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; - - let mut guard = state - .config - .write() - .map_err(|e| format!("写入配置锁失败: {e}"))?; - // 验证格式(根据应用类型) if !snippet.trim().is_empty() { - match app { - AppType::Claude | AppType::Gemini => { + match app_type.as_str() { + "claude" | "gemini" => { // 验证 JSON 格式 serde_json::from_str::(&snippet) .map_err(|e| format!("无效的 JSON 格式: {e}"))?; } - AppType::Codex => { + "codex" => { // TOML 格式暂不验证(或可使用 toml crate) // 注意:TOML 验证较为复杂,暂时跳过 } + _ => {} } } - guard.common_config_snippets.set( - &app, - if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }, - ); + let value = if snippet.trim().is_empty() { + None + } else { + Some(snippet) + }; - guard.save().map_err(|e| e.to_string())?; + state + .db + .set_config_snippet(&app_type, value) + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index a16787d8..fe2fde14 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -6,20 +6,22 @@ use tauri::State; use tauri_plugin_dialog::DialogExt; use crate::error::AppError; -use crate::services::ConfigService; +use crate::services::provider::ProviderService; use crate::store::AppState; -/// 导出配置文件 +/// 导出数据库为 SQL 备份 #[tauri::command] pub async fn export_config_to_file( #[allow(non_snake_case)] filePath: String, + state: State<'_, AppState>, ) -> Result { + let db = state.db.clone(); tauri::async_runtime::spawn_blocking(move || { let target_path = PathBuf::from(&filePath); - ConfigService::export_config_to_path(&target_path)?; + db.export_sql(&target_path)?; Ok::<_, AppError>(json!({ "success": true, - "message": "Configuration exported successfully", + "message": "SQL exported successfully", "filePath": filePath })) }) @@ -28,51 +30,49 @@ pub async fn export_config_to_file( .map_err(|e: AppError| e.to_string()) } -/// 从文件导入配置 +/// 从 SQL 备份导入数据库 #[tauri::command] pub async fn import_config_from_file( #[allow(non_snake_case)] filePath: String, state: State<'_, AppState>, ) -> Result { - let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || { + let db = state.db.clone(); + let db_for_state = db.clone(); + tauri::async_runtime::spawn_blocking(move || { let path_buf = PathBuf::from(&filePath); - ConfigService::load_config_for_import(&path_buf) + let backup_id = db.import_sql(&path_buf)?; + + // 导入后同步当前供应商到各自的 live 配置 + let app_state = AppState::new(db_for_state); + if let Err(err) = ProviderService::sync_current_from_db(&app_state) { + log::warn!("导入后同步 live 配置失败: {err}"); + } + + Ok::<_, AppError>(json!({ + "success": true, + "message": "SQL imported successfully", + "backupId": backup_id + })) }) .await .map_err(|e| format!("导入配置失败: {e}"))? - .map_err(|e: AppError| e.to_string())?; - - { - let mut guard = state - .config - .write() - .map_err(|e| AppError::from(e).to_string())?; - *guard = new_config; - } - - Ok(json!({ - "success": true, - "message": "Configuration imported successfully", - "backupId": backup_id - })) + .map_err(|e: AppError| e.to_string()) } -/// 同步当前供应商配置到对应的 live 文件 #[tauri::command] pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result { - { - let mut config_state = state - .config - .write() - .map_err(|e| AppError::from(e).to_string())?; - ConfigService::sync_current_providers_to_live(&mut config_state) - .map_err(|e| e.to_string())?; - } - - Ok(json!({ - "success": true, - "message": "Live configuration synchronized" - })) + let db = state.db.clone(); + tauri::async_runtime::spawn_blocking(move || { + let app_state = AppState::new(db); + ProviderService::sync_current_from_db(&app_state)?; + Ok::<_, AppError>(json!({ + "success": true, + "message": "Live configuration synchronized" + })) + }) + .await + .map_err(|e| format!("同步当前供应商失败: {e}"))? + .map_err(|e: AppError| e.to_string()) } /// 保存文件对话框 @@ -84,7 +84,7 @@ pub async fn save_file_dialog( let dialog = app.dialog(); let result = dialog .file() - .add_filter("JSON", &["json"]) + .add_filter("SQL", &["sql"]) .set_file_name(&defaultName) .blocking_save_file(); @@ -99,7 +99,7 @@ pub async fn open_file_dialog( let dialog = app.dialog(); let result = dialog .file() - .add_filter("JSON", &["json"]) + .add_filter("SQL", &["sql"]) .blocking_pick_file(); Ok(result.map(|p| p.to_string())) diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 17b788d4..7abea8e0 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -1,5 +1,6 @@ #![allow(non_snake_case)] +use indexmap::IndexMap; use std::collections::HashMap; use serde::Serialize; @@ -82,12 +83,8 @@ pub async fn upsert_mcp_server_in_config( // 读取现有的服务器(如果存在) let existing_server = { - let cfg = state.config.read().map_err(|e| e.to_string())?; - if let Some(servers) = &cfg.mcp.servers { - servers.get(&id).cloned() - } else { - None - } + let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?; + servers.get(&id).cloned() }; // 构建新的统一服务器结构 @@ -165,7 +162,7 @@ use crate::app_config::McpServer; #[tauri::command] pub async fn get_mcp_servers( state: State<'_, AppState>, -) -> Result, String> { +) -> Result, String> { McpService::get_all_servers(&state).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/prompt.rs b/src-tauri/src/commands/prompt.rs index 6064e2f5..20bd9f2a 100644 --- a/src-tauri/src/commands/prompt.rs +++ b/src-tauri/src/commands/prompt.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use indexmap::IndexMap; use std::str::FromStr; use tauri::State; @@ -12,7 +12,7 @@ use crate::store::AppState; pub async fn get_prompts( app: String, state: State<'_, AppState>, -) -> Result, String> { +) -> Result, String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 7f513891..280d7441 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use indexmap::IndexMap; use tauri::State; use crate::app_config::AppType; @@ -13,7 +13,7 @@ use std::str::FromStr; pub fn get_providers( state: State<'_, AppState>, app: String, -) -> Result, String> { +) -> Result, String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 3ed526c8..64ea9506 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -13,16 +13,34 @@ pub async fn get_skills( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - let repos = { - let config = app_state.config.read().map_err(|e| e.to_string())?; - config.skills.repos.clone() - }; + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; - service + let skills = service .0 .list_skills(repos) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // 自动同步本地已安装的 skills 到数据库 + // 这样用户在首次运行时,已有的 skills 会被自动记录 + let existing_states = app_state.db.get_skills().unwrap_or_default(); + + for skill in &skills { + if skill.installed && !existing_states.contains_key(&skill.directory) { + // 本地有该 skill,但数据库中没有记录,自动添加 + if let Err(e) = app_state.db.update_skill_state( + &skill.directory, + &SkillState { + installed: true, + installed_at: Utc::now(), + }, + ) { + log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e); + } + } + } + + Ok(skills) } #[tauri::command] @@ -32,10 +50,7 @@ pub async fn install_skill( app_state: State<'_, AppState>, ) -> Result { // 先在不持有写锁的情况下收集仓库与技能信息 - let repos = { - let config = app_state.config.read().map_err(|e| e.to_string())?; - config.skills.repos.clone() - }; + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let skills = service .0 @@ -85,19 +100,16 @@ pub async fn install_skill( .map_err(|e| e.to_string())?; } - { - let mut config = app_state.config.write().map_err(|e| e.to_string())?; - - config.skills.skills.insert( - directory.clone(), - SkillState { + app_state + .db + .update_skill_state( + &directory, + &SkillState { installed: true, installed_at: Utc::now(), }, - ); - } - - app_state.save().map_err(|e| e.to_string())?; + ) + .map_err(|e| e.to_string())?; Ok(true) } @@ -113,13 +125,17 @@ pub fn uninstall_skill( .uninstall_skill(directory.clone()) .map_err(|e| e.to_string())?; - { - let mut config = app_state.config.write().map_err(|e| e.to_string())?; - - config.skills.skills.remove(&directory); - } - - app_state.save().map_err(|e| e.to_string())?; + // Remove from database by setting installed = false + app_state + .db + .update_skill_state( + &directory, + &SkillState { + installed: false, + installed_at: Utc::now(), + }, + ) + .map_err(|e| e.to_string())?; Ok(true) } @@ -129,28 +145,19 @@ pub fn get_skill_repos( _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - let config = app_state.config.read().map_err(|e| e.to_string())?; - - Ok(config.skills.repos.clone()) + app_state.db.get_skill_repos().map_err(|e| e.to_string()) } #[tauri::command] pub fn add_skill_repo( repo: SkillRepo, - service: State<'_, SkillServiceState>, + _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - { - let mut config = app_state.config.write().map_err(|e| e.to_string())?; - - service - .0 - .add_repo(&mut config.skills, repo) - .map_err(|e| e.to_string())?; - } - - app_state.save().map_err(|e| e.to_string())?; - + app_state + .db + .save_skill_repo(&repo) + .map_err(|e| e.to_string())?; Ok(true) } @@ -158,19 +165,12 @@ pub fn add_skill_repo( pub fn remove_skill_repo( owner: String, name: String, - service: State<'_, SkillServiceState>, + _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - { - let mut config = app_state.config.write().map_err(|e| e.to_string())?; - - service - .0 - .remove_repo(&mut config.skills, owner, name) - .map_err(|e| e.to_string())?; - } - - app_state.save().map_err(|e| e.to_string())?; - + app_state + .db + .delete_skill_repo(&owner, &name) + .map_err(|e| e.to_string())?; Ok(true) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index dfd0756b..0b3cc9af 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -79,6 +79,7 @@ pub fn get_app_config_path() -> PathBuf { } /// 清理供应商名称,确保文件名安全 +#[allow(dead_code)] pub fn sanitize_provider_name(name: &str) -> String { name.chars() .map(|c| match c { @@ -90,6 +91,7 @@ pub fn sanitize_provider_name(name: &str) -> String { } /// 获取供应商配置文件路径 +#[allow(dead_code)] pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf { let base_name = provider_name .map(sanitize_provider_name) diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs new file mode 100644 index 00000000..486d7283 --- /dev/null +++ b/src-tauri/src/database.rs @@ -0,0 +1,1225 @@ +use crate::app_config::{McpApps, McpServer, MultiAppConfig}; +use crate::config::get_app_config_dir; +use crate::error::AppError; +use crate::prompt::Prompt; +use crate::provider::{Provider, ProviderMeta}; +use crate::services::skill::{SkillRepo, SkillState}; +use chrono::Utc; +use indexmap::IndexMap; +use rusqlite::backup::Backup; +use rusqlite::types::ValueRef; +use rusqlite::{params, Connection}; +use serde::Serialize; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tempfile::NamedTempFile; + +/// 安全地序列化 JSON,避免 unwrap panic +fn to_json_string(value: &T) -> Result { + serde_json::to_string(value) + .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}"))) +} + +/// 安全地获取 Mutex 锁,避免 unwrap panic +macro_rules! lock_conn { + ($mutex:expr) => { + $mutex + .lock() + .map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))? + }; +} + +const DB_BACKUP_RETAIN: usize = 10; + +pub struct Database { + // 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享 + // rusqlite::Connection 本身不是 Sync 的 + conn: Mutex, +} + +impl Database { + /// 初始化数据库连接并创建表 + pub fn init() -> Result { + let db_path = get_app_config_dir().join("cc-switch.db"); + + // 确保父目录存在 + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; + + // 启用外键约束 + conn.execute("PRAGMA foreign_keys = ON;", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let db = Self { + conn: Mutex::new(conn), + }; + db.create_tables()?; + + Ok(db) + } + + fn create_tables(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + Self::create_tables_on_conn(&conn) + } + + fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> { + // 1. Providers 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + website_url TEXT, + category TEXT, + created_at INTEGER, + sort_index INTEGER, + notes TEXT, + icon TEXT, + icon_color TEXT, + meta TEXT, + is_current BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (id, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 2. Provider Endpoints 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + url TEXT NOT NULL, + added_at INTEGER, + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ).map_err(|e| AppError::Database(e.to_string()))?; + + // 3. MCP Servers 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + homepage TEXT, + docs TEXT, + tags TEXT, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 4. Prompts 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS prompts ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT 1, + created_at INTEGER, + updated_at INTEGER, + PRIMARY KEY (id, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 5. Skills 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS skills ( + key TEXT PRIMARY KEY, + installed BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 6. Skill Repos 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS skill_repos ( + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + skills_path TEXT, + PRIMARY KEY (owner, name) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 7. Settings 表 (通用配置) + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 创建内存快照以避免长时间持有数据库锁 + fn snapshot_to_memory(&self) -> Result { + let conn = lock_conn!(self.conn); + let mut snapshot = + Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + + { + let backup = + Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Ok(snapshot) + } + + /// 导出为 SQLite 兼容的 SQL 文本 + pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { + let snapshot = self.snapshot_to_memory()?; + let dump = Self::dump_sql(&snapshot)?; + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + crate::config::atomic_write(target_path, dump.as_bytes()) + } + + /// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串) + pub fn import_sql(&self, source_path: &Path) -> Result { + if !source_path.exists() { + return Err(AppError::InvalidInput(format!( + "SQL 文件不存在: {}", + source_path.display() + ))); + } + + let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; + let sql_content = Self::sanitize_import_sql(&sql_raw); + + // 导入前备份现有数据库 + let backup_path = self.backup_database_file()?; + + // 在临时数据库执行导入,确保失败不会污染主库 + let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext { + context: "创建临时数据库文件失败".to_string(), + source: e, + })?; + let temp_path = temp_file.path().to_path_buf(); + let temp_conn = + Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; + + temp_conn + .execute_batch(&sql_content) + .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; + + // 补齐缺失表/索引并进行基础校验 + Self::create_tables_on_conn(&temp_conn)?; + Self::validate_basic_state(&temp_conn)?; + + // 使用 Backup 将临时库原子写回主库 + { + let mut main_conn = lock_conn!(self.conn); + let backup = Backup::new(&temp_conn, &mut main_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + let backup_id = backup_path + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default(); + + Ok(backup_id) + } + + /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错 + fn sanitize_import_sql(sql: &str) -> String { + let mut cleaned = String::new(); + let lower_keyword = "sqlite_sequence"; + + for stmt in sql.split(';') { + let trimmed = stmt.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.to_ascii_lowercase().contains(lower_keyword) { + continue; + } + + cleaned.push_str(trimmed); + cleaned.push_str(";\n"); + } + + cleaned + } + + /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) + fn backup_database_file(&self) -> Result, AppError> { + let db_path = get_app_config_dir().join("cc-switch.db"); + if !db_path.exists() { + return Ok(None); + } + + let backup_dir = db_path + .parent() + .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? + .join("backups"); + + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); + let backup_path = backup_dir.join(format!("{backup_id}.db")); + + { + let conn = lock_conn!(self.conn); + let mut dest_conn = + Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; + let backup = Backup::new(&conn, &mut dest_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Self::cleanup_db_backups(&backup_dir)?; + Ok(Some(backup_path)) + } + + fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { + let entries = match fs::read_dir(dir) { + Ok(iter) => iter + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "db") + .unwrap_or(false) + }) + .collect::>(), + Err(_) => return Ok(()), + }; + + if entries.len() <= DB_BACKUP_RETAIN { + return Ok(()); + } + + let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN); + let mut sorted = entries; + sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + + for entry in sorted.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err); + } + } + Ok(()) + } + + fn validate_basic_state(conn: &Connection) -> Result<(), AppError> { + let provider_count: i64 = conn + .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + let mcp_count: i64 = conn + .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + if provider_count == 0 && mcp_count == 0 { + return Err(AppError::Config( + "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(), + )); + } + Ok(()) + } + + fn dump_sql(conn: &Connection) -> Result { + let mut output = String::new(); + let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let user_version: i64 = conn + .query_row("PRAGMA user_version;", [], |row| row.get(0)) + .unwrap_or(0); + + output.push_str(&format!( + "-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n" + )); + output.push_str("PRAGMA foreign_keys=OFF;\n"); + output.push_str(&format!("PRAGMA user_version={user_version};\n")); + output.push_str("BEGIN TRANSACTION;\n"); + + // 导出 schema + let mut stmt = conn + .prepare( + "SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE sql NOT NULL AND type IN ('table','index','trigger','view') + ORDER BY type='table' DESC, name", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut tables = Vec::new(); + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(e.to_string()))?; + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?; + let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?; + let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?; + + // 跳过 SQLite 内部对象(如 sqlite_sequence) + if name.starts_with("sqlite_") { + continue; + } + + output.push_str(&sql); + output.push_str(";\n"); + + if obj_type == "table" && !name.starts_with("sqlite_") { + tables.push(name); + } + } + + // 导出数据 + for table in tables { + let columns = Self::get_table_columns(conn, &table)?; + if columns.is_empty() { + continue; + } + + let mut stmt = conn + .prepare(&format!("SELECT * FROM \"{table}\"")) + .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(e.to_string()))?; + + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let mut values = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + let value = row + .get_ref(idx) + .map_err(|e| AppError::Database(e.to_string()))?; + values.push(Self::format_sql_value(value)?); + } + + let cols = columns + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + output.push_str(&format!( + "INSERT INTO \"{table}\" ({cols}) VALUES ({});\n", + values.join(", ") + )); + } + } + + output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n"); + Ok(output) + } + + fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info(\"{table}\")")) + .map_err(|e| AppError::Database(e.to_string()))?; + let iter = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut columns = Vec::new(); + for col in iter { + columns.push(col.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(columns) + } + + fn format_sql_value(value: ValueRef<'_>) -> Result { + match value { + ValueRef::Null => Ok("NULL".to_string()), + ValueRef::Integer(i) => Ok(i.to_string()), + ValueRef::Real(f) => Ok(f.to_string()), + ValueRef::Text(t) => { + let text = std::str::from_utf8(t) + .map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?; + let escaped = text.replace('\'', "''"); + Ok(format!("'{escaped}'")) + } + ValueRef::Blob(bytes) => { + let mut s = String::from("X'"); + for b in bytes { + use std::fmt::Write; + let _ = write!(&mut s, "{b:02X}"); + } + s.push('\''); + Ok(s) + } + } + } + + /// 从 MultiAppConfig 迁移数据 + pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // 1. 迁移 Providers + for (app_key, manager) in &config.apps { + let app_type = app_key; // "claude", "codex", "gemini" + let current_id = &manager.current; + + for (id, provider) in &manager.providers { + let is_current = if id == current_id { 1 } else { 0 }; + + // 处理 meta 和 endpoints + let mut meta_clone = provider.meta.clone().unwrap_or_default(); + let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); + + tx.execute( + "INSERT OR REPLACE INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, is_current + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + id, + app_type, + provider.name, + to_json_string(&provider.settings_config)?, + provider.website_url, + provider.category, + provider.created_at, + provider.sort_index, + provider.notes, + provider.icon, + provider.icon_color, + to_json_string(&meta_clone)?, // 不含 endpoints 的 meta + is_current, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?; + + // 迁移 Endpoints + for (url, endpoint) in endpoints { + tx.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) + VALUES (?1, ?2, ?3, ?4)", + params![id, app_type, url, endpoint.added_at], + ) + .map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?; + } + } + } + + // 2. 迁移 MCP Servers + if let Some(servers) = &config.mcp.servers { + for (id, server) in servers { + tx.execute( + "INSERT OR REPLACE INTO mcp_servers ( + id, name, server_config, description, homepage, docs, tags, + enabled_claude, enabled_codex, enabled_gemini + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + id, + server.name, + to_json_string(&server.server)?, + server.description, + server.homepage, + server.docs, + to_json_string(&server.tags)?, + server.apps.claude, + server.apps.codex, + server.apps.gemini, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?; + } + } + + // 3. 迁移 Prompts + let migrate_prompts = + |prompts_map: &std::collections::HashMap, + app_type: &str| + -> Result<(), AppError> { + for (id, prompt) in prompts_map { + tx.execute( + "INSERT OR REPLACE INTO prompts ( + id, app_type, name, content, description, enabled, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, + app_type, + prompt.name, + prompt.content, + prompt.description, + prompt.enabled, + prompt.created_at, + prompt.updated_at, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?; + } + Ok(()) + }; + + migrate_prompts(&config.prompts.claude.prompts, "claude")?; + migrate_prompts(&config.prompts.codex.prompts, "codex")?; + migrate_prompts(&config.prompts.gemini.prompts, "gemini")?; + + // 4. 迁移 Skills + for (key, state) in &config.skills.skills { + tx.execute( + "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params![key, state.installed, state.installed_at.timestamp()], + ) + .map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; + } + + for repo in &config.skills.repos { + tx.execute( + "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", + params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], + ).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?; + } + + // 5. 迁移 Common Config + if let Some(snippet) = &config.common_config_snippets.claude { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_claude", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + if let Some(snippet) = &config.common_config_snippets.codex { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_codex", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + if let Some(snippet) = &config.common_config_snippets.gemini { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_gemini", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + + tx.commit() + .map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?; + Ok(()) + } + + /// 检查数据库是否为空(需要首次导入) + /// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断 + pub fn is_empty_for_first_import(&self) -> Result { + let conn = lock_conn!(self.conn); + + // 检查是否有 MCP 服务器 + let mcp_count: i64 = conn + .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 检查是否有提示词 + let prompt_count: i64 = conn + .query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 检查是否有 Skills 仓库 + let skill_repo_count: i64 = conn + .query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 检查是否有供应商 + let provider_count: i64 = conn + .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 如果四者都为 0,说明是空数据库 + Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0) + } + + // --- Providers DAO --- + + pub fn get_all_providers( + &self, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn.prepare( + "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta + FROM providers WHERE app_type = ?1 + ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let provider_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let settings_config_str: String = row.get(2)?; + let website_url: Option = row.get(3)?; + let category: Option = row.get(4)?; + let created_at: Option = row.get(5)?; + let sort_index: Option = row.get(6)?; + let notes: Option = row.get(7)?; + let icon: Option = row.get(8)?; + let icon_color: Option = row.get(9)?; + let meta_str: String = row.get(10)?; + + let settings_config = + serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); + let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); + + Ok(( + id, + Provider { + id: "".to_string(), // Placeholder, set below + name, + settings_config, + website_url, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon, + icon_color, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut providers = IndexMap::new(); + for provider_res in provider_iter { + let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; + provider.id = id.clone(); + + // Load endpoints + let mut stmt_endpoints = conn.prepare( + "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let endpoints_iter = stmt_endpoints + .query_map(params![id, app_type], |row| { + let url: String = row.get(0)?; + let added_at: Option = row.get(1)?; + Ok(( + url, + crate::settings::CustomEndpoint { + url: "".to_string(), + added_at: added_at.unwrap_or(0), + last_used: None, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut custom_endpoints = HashMap::new(); + for ep_res in endpoints_iter { + let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?; + ep.url = url.clone(); + custom_endpoints.insert(url, ep); + } + + if let Some(meta) = &mut provider.meta { + meta.custom_endpoints = custom_endpoints; + } + + providers.insert(id, provider); + } + + Ok(providers) + } + + pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt + .query(params![app_type]) + .map_err(|e| AppError::Database(e.to_string()))?; + + if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + Ok(Some( + row.get(0).map_err(|e| AppError::Database(e.to_string()))?, + )) + } else { + Ok(None) + } + } + + pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // Handle meta and endpoints + let mut meta_clone = provider.meta.clone().unwrap_or_default(); + let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); + + // Check if it exists to preserve is_current + let is_current: bool = tx + .query_row( + "SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2", + params![provider.id, app_type], + |row| row.get(0), + ) + .unwrap_or(false); + + tx.execute( + "INSERT OR REPLACE INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, is_current + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + provider.id, + app_type, + provider.name, + serde_json::to_string(&provider.settings_config).unwrap(), + provider.website_url, + provider.category, + provider.created_at, + provider.sort_index, + provider.notes, + provider.icon, + provider.icon_color, + serde_json::to_string(&meta_clone).unwrap(), + is_current, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // Sync endpoints: Delete all and re-insert + tx.execute( + "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2", + params![provider.id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + for (url, endpoint) in endpoints { + tx.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) + VALUES (?1, ?2, ?3, ?4)", + params![provider.id, app_type, url, endpoint.added_at], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM providers WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // Reset all to 0 + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // Set new current + tx.execute( + "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn add_custom_endpoint( + &self, + app_type: &str, + provider_id: &str, + url: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let added_at = chrono::Utc::now().timestamp_millis(); + conn.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)", + params![provider_id, app_type, url, added_at], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn remove_custom_endpoint( + &self, + app_type: &str, + provider_id: &str, + url: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3", + params![provider_id, app_type, url], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // --- MCP Servers DAO --- + + pub fn get_all_mcp_servers(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn.prepare( + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini + FROM mcp_servers + ORDER BY name ASC, id ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let server_iter = stmt + .query_map([], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let server_config_str: String = row.get(2)?; + let description: Option = row.get(3)?; + let homepage: Option = row.get(4)?; + let docs: Option = row.get(5)?; + let tags_str: String = row.get(6)?; + let enabled_claude: bool = row.get(7)?; + let enabled_codex: bool = row.get(8)?; + let enabled_gemini: bool = row.get(9)?; + + let server = serde_json::from_str(&server_config_str).unwrap_or_default(); + let tags = serde_json::from_str(&tags_str).unwrap_or_default(); + + Ok(( + id.clone(), + McpServer { + id, + name, + server, + apps: McpApps { + claude: enabled_claude, + codex: enabled_codex, + gemini: enabled_gemini, + }, + description, + homepage, + docs, + tags, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut servers = IndexMap::new(); + for server_res in server_iter { + let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?; + servers.insert(id, server); + } + Ok(servers) + } + + pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO mcp_servers ( + id, name, server_config, description, homepage, docs, tags, + enabled_claude, enabled_codex, enabled_gemini + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + server.id, + server.name, + serde_json::to_string(&server.server).unwrap(), + server.description, + server.homepage, + server.docs, + serde_json::to_string(&server.tags).unwrap(), + server.apps.claude, + server.apps.codex, + server.apps.gemini, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // --- Prompts DAO --- + + pub fn get_prompts(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, content, description, enabled, created_at, updated_at + FROM prompts WHERE app_type = ?1 + ORDER BY created_at ASC, id ASC", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let prompt_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let content: String = row.get(2)?; + let description: Option = row.get(3)?; + let enabled: bool = row.get(4)?; + let created_at: Option = row.get(5)?; + let updated_at: Option = row.get(6)?; + + Ok(( + id.clone(), + Prompt { + id, + name, + content, + description, + enabled, + created_at, + updated_at, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut prompts = IndexMap::new(); + for prompt_res in prompt_iter { + let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?; + prompts.insert(id, prompt); + } + Ok(prompts) + } + + pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO prompts ( + id, app_type, name, content, description, enabled, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + prompt.id, + app_type, + prompt.name, + prompt.content, + prompt.description, + prompt.enabled, + prompt.created_at, + prompt.updated_at, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM prompts WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // --- Skills DAO --- + + pub fn get_skills(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC") + .map_err(|e| AppError::Database(e.to_string()))?; + + let skill_iter = stmt + .query_map([], |row| { + let key: String = row.get(0)?; + let installed: bool = row.get(1)?; + let installed_at_ts: i64 = row.get(2)?; + + let installed_at = + chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); + + Ok(( + key, + SkillState { + installed, + installed_at, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut skills = IndexMap::new(); + for skill_res in skill_iter { + let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; + skills.insert(key, skill); + } + Ok(skills) + } + + pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params![key, state.installed, state.installed_at.timestamp()], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn get_skill_repos(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC") + .map_err(|e| AppError::Database(e.to_string()))?; + + let repo_iter = stmt + .query_map([], |row| { + Ok(SkillRepo { + owner: row.get(0)?, + name: row.get(1)?, + branch: row.get(2)?, + enabled: row.get(3)?, + skills_path: row.get(4)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut repos = Vec::new(); + for repo_res in repo_iter { + repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(repos) + } + + pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", + params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2", + params![owner, name], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 初始化默认的 Skill 仓库(首次启动时调用) + pub fn init_default_skill_repos(&self) -> Result { + // 检查是否已有仓库 + let existing = self.get_skill_repos()?; + if !existing.is_empty() { + return Ok(0); + } + + // 获取默认仓库列表 + let default_store = crate::services::skill::SkillStore::default(); + let mut count = 0; + + for repo in &default_store.repos { + self.save_skill_repo(repo)?; + count += 1; + } + + log::info!("初始化默认 Skill 仓库完成,共 {count} 个"); + Ok(count) + } + + // --- Settings DAO --- + + pub fn get_setting(&self, key: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT value FROM settings WHERE key = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt + .query(params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + + if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + Ok(Some( + row.get(0).map_err(|e| AppError::Database(e.to_string()))?, + )) + } else { + Ok(None) + } + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // --- Config Snippets Helper Methods --- + + pub fn get_config_snippet(&self, app_type: &str) -> Result, AppError> { + self.get_setting(&format!("common_config_{app_type}")) + } + + pub fn set_config_snippet( + &self, + app_type: &str, + snippet: Option, + ) -> Result<(), AppError> { + let key = format!("common_config_{app_type}"); + if let Some(value) = snippet { + self.set_setting(&key, &value) + } else { + // Delete if None + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index cd4cf3bd..b8f23b0b 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -50,6 +50,8 @@ pub enum AppError { zh: String, en: String, }, + #[error("数据库错误: {0}")] + Database(String), } impl AppError { diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs index a3463375..ce996627 100644 --- a/src-tauri/src/init_status.rs +++ b/src-tauri/src/init_status.rs @@ -13,7 +13,9 @@ fn cell() -> &'static RwLock> { INIT_ERROR.get_or_init(|| RwLock::new(None)) } +#[allow(dead_code)] pub fn set_init_error(payload: InitErrorPayload) { + #[allow(clippy::unwrap_used)] if let Ok(mut guard) = cell().write() { *guard = Some(payload); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9319a87a..f9fa2d5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod database; mod deeplink; mod error; mod gemini_config; // 新增 @@ -206,8 +207,6 @@ fn create_tray_menu( let app_settings = crate::settings::get_settings(); let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); - let config = app_state.config.read().map_err(AppError::from)?; - let mut menu_builder = MenuBuilder::new(app); // 顶部:打开主界面 @@ -218,13 +217,20 @@ fn create_tray_menu( // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) for section in TRAY_SECTIONS.iter() { - menu_builder = append_provider_section( - app, - menu_builder, - config.get_manager(§ion.app_type), - section, - &tray_texts, - )?; + let app_type_str = section.app_type.as_str(); + let providers = app_state.db.get_all_providers(app_type_str)?; + let current_id = app_state + .db + .get_current_provider(app_type_str)? + .unwrap_or_default(); + + let manager = crate::provider::ProviderManager { + providers, + current: current_id, + }; + + menu_builder = + append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?; } // 分隔符和退出菜单 @@ -489,24 +495,31 @@ pub fn run() { use objc2::runtime::AnyObject; use objc2_app_kit::NSColor; - let ns_window_ptr = window.ns_window().unwrap(); - let ns_window: Retained = - unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() }; + match window.ns_window() { + Ok(ns_window_ptr) => { + if let Some(ns_window) = + unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) } + { + // 使用与主界面 banner 相同的蓝色 #3498db + // #3498db = RGB(52, 152, 219) + let bg_color = unsafe { + NSColor::colorWithRed_green_blue_alpha( + 52.0 / 255.0, // R: 52 + 152.0 / 255.0, // G: 152 + 219.0 / 255.0, // B: 219 + 1.0, // Alpha: 1.0 + ) + }; - // 使用与主界面 banner 相同的蓝色 #3498db - // #3498db = RGB(52, 152, 219) - let bg_color = unsafe { - NSColor::colorWithRed_green_blue_alpha( - 52.0 / 255.0, // R: 52 - 152.0 / 255.0, // G: 152 - 219.0 / 255.0, // B: 219 - 1.0, // Alpha: 1.0 - ) - }; - - unsafe { - use objc2::msg_send; - let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color]; + unsafe { + use objc2::msg_send; + let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color]; + } + } else { + log::warn!("Failed to retain NSWindow reference"); + } + } + Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"), } } } @@ -523,42 +536,157 @@ pub fn run() { // 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径 app_store::refresh_app_config_dir_override(app.handle()); - // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) - // 如果配置解析失败,则向前端发送错误事件并提前结束 setup(不落盘、不覆盖配置)。 - let app_state = match AppState::try_new() { - Ok(state) => state, - Err(err) => { - let path = crate::config::get_app_config_path(); - let payload_json = serde_json::json!({ - "path": path.display().to_string(), - "error": err.to_string(), - }); - // 事件通知(可能早于前端订阅,不保证送达) - if let Err(e) = app.emit("configLoadError", payload_json) { - log::error!("发射配置加载错误事件失败: {e}"); - } - // 同时缓存错误,供前端启动阶段主动拉取 - crate::init_status::set_init_error(crate::init_status::InitErrorPayload { - path: path.display().to_string(), - error: err.to_string(), - }); - // 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。 - return Ok(()); + // 初始化数据库 + let app_config_dir = crate::config::get_app_config_dir(); + let db_path = app_config_dir.join("cc-switch.db"); + let json_path = app_config_dir.join("config.json"); + + // Check if migration is needed (DB doesn't exist but JSON does) + let migration_needed = !db_path.exists() && json_path.exists(); + + let db = match crate::database::Database::init() { + Ok(db) => Arc::new(db), + Err(e) => { + log::error!("Failed to init database: {e}"); + // 这里的错误处理比较棘手,因为 setup 返回 Result> + // 我们暂时记录日志并让应用继续运行(可能会崩溃)或者返回错误 + return Err(Box::new(e)); } }; + if migration_needed { + log::info!("Starting migration from config.json to SQLite..."); + match crate::app_config::MultiAppConfig::load() { + Ok(config) => { + if let Err(e) = db.migrate_from_json(&config) { + log::error!("Migration failed: {e}"); + } else { + log::info!("Migration successful"); + // Optional: Rename config.json + // let _ = std::fs::rename(&json_path, json_path.with_extension("json.bak")); + } + } + Err(e) => log::error!("Failed to load config.json for migration: {e}"), + } + } + + crate::settings::bind_db(db.clone()); + let app_state = AppState::new(db); + + // 检查是否需要首次导入(数据库为空) + let need_first_import = app_state + .db + .is_empty_for_first_import() + .unwrap_or_else(|e| { + log::warn!("Failed to check if database is empty: {e}"); + false + }); + + if need_first_import { + // 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置 + log::info!( + "Empty database detected, importing existing configurations and initializing defaults..." + ); + + // 1. 初始化默认 Skills 仓库(3个) + match app_state.db.init_default_skill_repos() { + Ok(count) if count > 0 => { + log::info!("✓ Initialized {count} default skill repositories"); + } + Ok(_) => log::debug!("No default skill repositories to initialize"), + Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"), + } + + // 2. 导入供应商配置(从 live 配置文件) + for app in [ + crate::app_config::AppType::Claude, + crate::app_config::AppType::Codex, + crate::app_config::AppType::Gemini, + ] { + match crate::services::provider::ProviderService::import_default_config( + &app_state, + app.clone(), + ) { + Ok(_) => { + log::info!("✓ Imported default provider for {}", app.as_str()); + } + Err(e) => { + log::debug!( + "○ No default provider to import for {}: {}", + app.as_str(), + e + ); + } + } + } + + // 3. 导入 MCP 服务器配置 + match crate::services::mcp::McpService::import_from_claude(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} MCP server(s) from Claude"); + } + Ok(_) => log::debug!("○ No Claude MCP servers found to import"), + Err(e) => log::warn!("✗ Failed to import Claude MCP: {e}"), + } + + match crate::services::mcp::McpService::import_from_codex(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} MCP server(s) from Codex"); + } + Ok(_) => log::debug!("○ No Codex MCP servers found to import"), + Err(e) => log::warn!("✗ Failed to import Codex MCP: {e}"), + } + + match crate::services::mcp::McpService::import_from_gemini(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} MCP server(s) from Gemini"); + } + Ok(_) => log::debug!("○ No Gemini MCP servers found to import"), + Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"), + } + + // 4. 导入提示词文件 + match crate::services::prompt::PromptService::import_from_file_on_first_launch( + &app_state, + crate::app_config::AppType::Claude, + ) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} prompt(s) from Claude"); + } + Ok(_) => log::debug!("○ No Claude prompt file found to import"), + Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"), + } + + match crate::services::prompt::PromptService::import_from_file_on_first_launch( + &app_state, + crate::app_config::AppType::Codex, + ) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} prompt(s) from Codex"); + } + Ok(_) => log::debug!("○ No Codex prompt file found to import"), + Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"), + } + + match crate::services::prompt::PromptService::import_from_file_on_first_launch( + &app_state, + crate::app_config::AppType::Gemini, + ) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} prompt(s) from Gemini"); + } + Ok(_) => log::debug!("○ No Gemini prompt file found to import"), + Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"), + } + + log::info!("First-time import completed"); + } + // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { log::warn!("迁移 app_config_dir 失败: {e}"); } - // 确保配置结构就绪(已移除旧版本的副本迁移逻辑) - { - let mut config_guard = app_state.config.write().unwrap(); - config_guard.ensure_app(&app_config::AppType::Claude); - config_guard.ensure_app(&app_config::AppType::Codex); - } - // 启动阶段不再无条件保存,避免意外覆盖用户配置。 // 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API) @@ -611,7 +739,11 @@ pub fn run() { .show_menu_on_left_click(true); // 统一使用应用默认图标;待托盘模板图标就绪后再启用 - tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone()); + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } else { + log::warn!("Failed to get default window icon for tray"); + } let _tray = tray_builder.build(app)?; // 将同一个实例注入到全局状态,避免重复创建导致的不一致 diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 30289d44..edee1d48 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -348,10 +348,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?; // 确保新结构存在 - if config.mcp.servers.is_none() { - config.mcp.servers = Some(HashMap::new()); - } - let servers = config.mcp.servers.as_mut().unwrap(); + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); let mut changed_total = 0usize; @@ -724,10 +718,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Option toml_arr.push(s.as_str()), - Value::Number(n) if n.is_i64() => toml_arr.push(n.as_i64().unwrap()), - Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()), + Value::Number(n) if n.is_i64() => { + if let Some(i) = n.as_i64() { + toml_arr.push(i); + } else { + all_same_type = false; + break; + } + } + Value::Number(n) if n.is_f64() => { + if let Some(f) = n.as_f64() { + toml_arr.push(f); + } else { + all_same_type = false; + break; + } + } Value::Bool(b) => toml_arr.push(*b), _ => { all_same_type = false; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 2d753b5e..75e01f48 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -1,3 +1,4 @@ +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -64,7 +65,7 @@ impl Provider { /// 供应商管理器 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderManager { - pub providers: HashMap, + pub providers: IndexMap, pub current: String, } @@ -154,7 +155,7 @@ pub struct ProviderMeta { impl ProviderManager { /// 获取所有供应商 - pub fn get_all_providers(&self) -> &HashMap { + pub fn get_all_providers(&self) -> &IndexMap { &self.providers } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 0c35eca7..3cc860cf 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -109,7 +109,17 @@ impl ConfigService { } /// 将外部配置文件内容加载并写入应用状态。 - pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result { + /// TODO: 需要重构以使用数据库而不是 JSON 配置 + pub fn import_config_from_path( + _file_path: &Path, + _state: &AppState, + ) -> Result { + // TODO: 实现基于数据库的导入逻辑 + Err(AppError::Message( + "配置导入功能正在重构中,暂时不可用".to_string(), + )) + + /* 旧的实现,需要重构: let (new_config, backup_id) = Self::load_config_for_import(file_path)?; { @@ -118,6 +128,7 @@ impl ConfigService { } Ok(backup_id) + */ } /// 同步当前供应商到对应的 live 配置。 diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b504a061..4f95c950 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -1,6 +1,7 @@ +use indexmap::IndexMap; use std::collections::HashMap; -use crate::app_config::{AppType, McpServer, MultiAppConfig}; +use crate::app_config::{AppType, McpServer}; use crate::error::AppError; use crate::mcp; use crate::store::AppState; @@ -10,40 +11,13 @@ pub struct McpService; impl McpService { /// 获取所有 MCP 服务器(统一结构) - pub fn get_all_servers(state: &AppState) -> Result, AppError> { - let cfg = state.config.read()?; - - // 如果是新结构,直接返回 - if let Some(servers) = &cfg.mcp.servers { - return Ok(servers.clone()); - } - - // 理论上不应该走到这里,因为 load 时会自动迁移 - Err(AppError::localized( - "mcp.old_structure", - "检测到旧版 MCP 结构,请重启应用完成迁移", - "Old MCP structure detected, please restart app to complete migration", - )) + pub fn get_all_servers(state: &AppState) -> Result, AppError> { + state.db.get_all_mcp_servers() } /// 添加或更新 MCP 服务器 pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { - { - let mut cfg = state.config.write()?; - - // 确保 servers 字段存在 - if cfg.mcp.servers.is_none() { - cfg.mcp.servers = Some(HashMap::new()); - } - - let servers = cfg.mcp.servers.as_mut().unwrap(); - let id = server.id.clone(); - - // 插入或更新 - servers.insert(id, server.clone()); - } - - state.save()?; + state.db.save_mcp_server(&server)?; // 同步到各个启用的应用 Self::sync_server_to_apps(state, &server)?; @@ -53,18 +27,10 @@ impl McpService { /// 删除 MCP 服务器 pub fn delete_server(state: &AppState, id: &str) -> Result { - let server = { - let mut cfg = state.config.write()?; - - if let Some(servers) = &mut cfg.mcp.servers { - servers.remove(id) - } else { - None - } - }; + let server = state.db.get_all_mcp_servers()?.shift_remove(id); if let Some(server) = server { - state.save()?; + state.db.delete_mcp_server(id)?; // 从所有应用的 live 配置中移除 Self::remove_server_from_all_apps(state, id, &server)?; @@ -81,27 +47,15 @@ impl McpService { app: AppType, enabled: bool, ) -> Result<(), AppError> { - let server = { - let mut cfg = state.config.write()?; + let mut servers = state.db.get_all_mcp_servers()?; - if let Some(servers) = &mut cfg.mcp.servers { - if let Some(server) = servers.get_mut(server_id) { - server.apps.set_enabled_for(&app, enabled); - Some(server.clone()) - } else { - None - } - } else { - None - } - }; - - if let Some(server) = server { - state.save()?; + if let Some(server) = servers.get_mut(server_id) { + server.apps.set_enabled_for(&app, enabled); + state.db.save_mcp_server(server)?; // 同步到对应应用 if enabled { - Self::sync_server_to_app(state, &server, &app)?; + Self::sync_server_to_app(state, server, &app)?; } else { Self::remove_server_from_app(state, server_id, &app)?; } @@ -111,11 +65,9 @@ impl McpService { } /// 将 MCP 服务器同步到所有启用的应用 - fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> { - let cfg = state.config.read()?; - + fn sync_server_to_apps(_state: &AppState, server: &McpServer) -> Result<(), AppError> { for app in server.apps.enabled_apps() { - Self::sync_server_to_app_internal(&cfg, server, &app)?; + Self::sync_server_to_app_no_config(server, &app)?; } Ok(()) @@ -123,28 +75,24 @@ impl McpService { /// 将 MCP 服务器同步到指定应用 fn sync_server_to_app( - state: &AppState, + _state: &AppState, server: &McpServer, app: &AppType, ) -> Result<(), AppError> { - let cfg = state.config.read()?; - Self::sync_server_to_app_internal(&cfg, server, app) + Self::sync_server_to_app_no_config(server, app) } - fn sync_server_to_app_internal( - cfg: &MultiAppConfig, - server: &McpServer, - app: &AppType, - ) -> Result<(), AppError> { + fn sync_server_to_app_no_config(server: &McpServer, app: &AppType) -> Result<(), AppError> { match app { AppType::Claude => { - mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?; + mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?; } AppType::Codex => { - mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?; + // Codex uses TOML format, must use the correct function + mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?; } AppType::Gemini => { - mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?; + mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; } } Ok(()) @@ -233,28 +181,67 @@ impl McpService { /// 从 Claude 导入 MCP(v3.7.0 已更新为统一结构) pub fn import_from_claude(state: &AppState) -> Result { - let mut cfg = state.config.write()?; - let count = mcp::import_from_claude(&mut cfg)?; - drop(cfg); - state.save()?; + // 创建临时 MultiAppConfig 用于导入 + let mut temp_config = crate::app_config::MultiAppConfig::default(); + + // 调用原有的导入逻辑(从 mcp.rs) + let count = crate::mcp::import_from_claude(&mut temp_config)?; + + // 如果有导入的服务器,保存到数据库 + if count > 0 { + if let Some(servers) = &temp_config.mcp.servers { + for server in servers.values() { + state.db.save_mcp_server(server)?; + // 同步到 Claude live 配置 + Self::sync_server_to_apps(state, server)?; + } + } + } + Ok(count) } /// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构) pub fn import_from_codex(state: &AppState) -> Result { - let mut cfg = state.config.write()?; - let count = mcp::import_from_codex(&mut cfg)?; - drop(cfg); - state.save()?; + // 创建临时 MultiAppConfig 用于导入 + let mut temp_config = crate::app_config::MultiAppConfig::default(); + + // 调用原有的导入逻辑(从 mcp.rs) + let count = crate::mcp::import_from_codex(&mut temp_config)?; + + // 如果有导入的服务器,保存到数据库 + if count > 0 { + if let Some(servers) = &temp_config.mcp.servers { + for server in servers.values() { + state.db.save_mcp_server(server)?; + // 同步到 Codex live 配置 + Self::sync_server_to_apps(state, server)?; + } + } + } + Ok(count) } /// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构) pub fn import_from_gemini(state: &AppState) -> Result { - let mut cfg = state.config.write()?; - let count = mcp::import_from_gemini(&mut cfg)?; - drop(cfg); - state.save()?; + // 创建临时 MultiAppConfig 用于导入 + let mut temp_config = crate::app_config::MultiAppConfig::default(); + + // 调用原有的导入逻辑(从 mcp.rs) + let count = crate::mcp::import_from_gemini(&mut temp_config)?; + + // 如果有导入的服务器,保存到数据库 + if count > 0 { + if let Some(servers) = &temp_config.mcp.servers { + for server in servers.values() { + state.db.save_mcp_server(server)?; + // 同步到 Gemini live 配置 + Self::sync_server_to_apps(state, server)?; + } + } + } + Ok(count) } } diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 42a03f1b..dd8c546c 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use indexmap::IndexMap; use crate::app_config::AppType; use crate::config::write_text_file; @@ -7,40 +7,34 @@ use crate::prompt::Prompt; use crate::prompt_files::prompt_file_path; use crate::store::AppState; +/// 安全地获取当前 Unix 时间戳 +fn get_unix_timestamp() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .map_err(|e| AppError::Message(format!("Failed to get system time: {e}"))) +} + pub struct PromptService; impl PromptService { pub fn get_prompts( state: &AppState, app: AppType, - ) -> Result, AppError> { - let cfg = state.config.read()?; - let prompts = match app { - AppType::Claude => &cfg.prompts.claude.prompts, - AppType::Codex => &cfg.prompts.codex.prompts, - AppType::Gemini => &cfg.prompts.gemini.prompts, - }; - Ok(prompts.clone()) + ) -> Result, AppError> { + state.db.get_prompts(app.as_str()) } pub fn upsert_prompt( state: &AppState, app: AppType, - id: &str, + _id: &str, prompt: Prompt, ) -> Result<(), AppError> { // 检查是否为已启用的提示词 let is_enabled = prompt.enabled; - let mut cfg = state.config.write()?; - let prompts = match app { - AppType::Claude => &mut cfg.prompts.claude.prompts, - AppType::Codex => &mut cfg.prompts.codex.prompts, - AppType::Gemini => &mut cfg.prompts.gemini.prompts, - }; - prompts.insert(id.to_string(), prompt.clone()); - drop(cfg); - state.save()?; + state.db.save_prompt(app.as_str(), &prompt)?; // 如果是已启用的提示词,同步更新到对应的文件 if is_enabled { @@ -52,12 +46,7 @@ impl PromptService { } pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { - let mut cfg = state.config.write()?; - let prompts = match app { - AppType::Claude => &mut cfg.prompts.claude.prompts, - AppType::Codex => &mut cfg.prompts.codex.prompts, - AppType::Gemini => &mut cfg.prompts.gemini.prompts, - }; + let prompts = state.db.get_prompts(app.as_str())?; if let Some(prompt) = prompts.get(id) { if prompt.enabled { @@ -65,9 +54,7 @@ impl PromptService { } } - prompts.remove(id); - drop(cfg); - state.save()?; + state.db.delete_prompt(app.as_str(), id)?; Ok(()) } @@ -77,12 +64,7 @@ impl PromptService { if target_path.exists() { if let Ok(live_content) = std::fs::read_to_string(&target_path) { if !live_content.trim().is_empty() { - let mut cfg = state.config.write()?; - let prompts = match app { - AppType::Claude => &mut cfg.prompts.claude.prompts, - AppType::Codex => &mut cfg.prompts.codex.prompts, - AppType::Gemini => &mut cfg.prompts.gemini.prompts, - }; + let mut prompts = state.db.get_prompts(app.as_str())?; // 尝试回填到当前已启用的提示词 if let Some((enabled_id, enabled_prompt)) = prompts @@ -90,15 +72,11 @@ impl PromptService { .find(|(_, p)| p.enabled) .map(|(id, p)| (id.clone(), p)) { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let timestamp = get_unix_timestamp()?; enabled_prompt.content = live_content.clone(); enabled_prompt.updated_at = Some(timestamp); log::info!("回填 live 提示词内容到已启用项: {enabled_id}"); - drop(cfg); // 释放锁后保存,避免死锁 - state.save()?; // 第一次保存:回填后立即持久化 + state.db.save_prompt(app.as_str(), enabled_prompt)?; } else { // 没有已启用的提示词,则创建一次备份(避免重复备份) let content_exists = prompts @@ -122,13 +100,8 @@ impl PromptService { created_at: Some(timestamp), updated_at: Some(timestamp), }; - prompts.insert(backup_id.clone(), backup_prompt); log::info!("回填 live 提示词内容,创建备份: {backup_id}"); - drop(cfg); // 释放锁后保存 - state.save()?; // 第一次保存:回填后立即持久化 - } else { - // 即使内容已存在,也无需重复备份;但不需要保存任何更改 - drop(cfg); + state.db.save_prompt(app.as_str(), &backup_prompt)?; } } } @@ -136,12 +109,7 @@ impl PromptService { } // 启用目标提示词并写入文件 - let mut cfg = state.config.write()?; - let prompts = match app { - AppType::Claude => &mut cfg.prompts.claude.prompts, - AppType::Codex => &mut cfg.prompts.codex.prompts, - AppType::Gemini => &mut cfg.prompts.gemini.prompts, - }; + let mut prompts = state.db.get_prompts(app.as_str())?; for prompt in prompts.values_mut() { prompt.enabled = false; @@ -150,12 +118,16 @@ impl PromptService { if let Some(prompt) = prompts.get_mut(id) { prompt.enabled = true; write_text_file(&target_path, &prompt.content)?; // 原子写入 + state.db.save_prompt(app.as_str(), prompt)?; } else { return Err(AppError::InvalidInput(format!("提示词 {id} 不存在"))); } - drop(cfg); - state.save()?; // 第二次保存:启用目标提示词并写入文件后 + // Save all prompts to disable others + for (_, prompt) in prompts.iter() { + state.db.save_prompt(app.as_str(), prompt)?; + } + Ok(()) } @@ -168,10 +140,7 @@ impl PromptService { let content = std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let timestamp = get_unix_timestamp()?; let id = format!("imported-{timestamp}"); let prompt = Prompt { @@ -200,4 +169,56 @@ impl PromptService { std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?; Ok(Some(content)) } + + /// 首次启动时从现有提示词文件自动导入(如果存在) + /// 返回导入的数量 + pub fn import_from_file_on_first_launch( + state: &AppState, + app: AppType, + ) -> Result { + let file_path = prompt_file_path(&app)?; + + // 检查文件是否存在 + if !file_path.exists() { + return Ok(0); + } + + // 读取文件内容 + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); + return Ok(0); + } + }; + + // 检查内容是否为空 + if content.trim().is_empty() { + return Ok(0); + } + + log::info!("发现提示词文件,自动导入: {file_path:?}"); + + // 创建提示词对象 + let timestamp = get_unix_timestamp()?; + let id = format!("auto-imported-{timestamp}"); + let prompt = Prompt { + id: id.clone(), + name: format!( + "Auto-imported Prompt {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content, + description: Some("Automatically imported on first launch".to_string()), + enabled: true, // 首次导入时自动启用 + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // 保存到数据库 + state.db.save_prompt(app.as_str(), &prompt)?; + + log::info!("自动导入完成: {}", app.as_str()); + Ok(1) + } } diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs index 2bc7f293..40cbfb84 100644 --- a/src-tauri/src/services/provider.rs +++ b/src-tauri/src/services/provider.rs @@ -1,17 +1,18 @@ +use indexmap::IndexMap; use regex::Regex; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::app_config::{AppType, MultiAppConfig}; +use crate::app_config::AppType; use crate::codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; use crate::config::{ - delete_file, get_claude_settings_path, get_provider_config_path, read_json_file, - write_json_file, write_text_file, + delete_file, get_claude_settings_path, read_json_file, write_json_file, write_text_file, }; use crate::error::AppError; -use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult}; +use crate::provider::{Provider, UsageData, UsageResult}; +use crate::services::mcp::McpService; use crate::settings::{self, CustomEndpoint}; use crate::store::AppState; use crate::usage_script; @@ -20,6 +21,7 @@ use crate::usage_script; pub struct ProviderService; #[derive(Clone)] +#[allow(dead_code)] enum LiveSnapshot { Claude { settings: Option, @@ -35,6 +37,7 @@ enum LiveSnapshot { } #[derive(Clone)] +#[allow(dead_code)] struct PostCommitAction { app_type: AppType, provider: Provider, @@ -44,6 +47,7 @@ struct PostCommitAction { } impl LiveSnapshot { + #[allow(dead_code)] fn restore(&self) -> Result<(), AppError> { match self { LiveSnapshot::Claude { settings } => { @@ -421,9 +425,9 @@ impl ProviderService { /// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键 fn normalize_claude_models_in_value(settings: &mut Value) -> bool { let mut changed = false; - let env = match settings.get_mut("env") { - Some(v) if v.is_object() => v.as_object_mut().unwrap(), - _ => return changed, + let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) { + Some(obj) => obj, + None => return changed, }; let model = env @@ -498,246 +502,93 @@ impl ProviderService { } } } - fn run_transaction(state: &AppState, f: F) -> Result - where - F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, - { - let mut guard = state.config.write().map_err(AppError::from)?; - let original = guard.clone(); - let (result, action) = match f(&mut guard) { - Ok(value) => value, - Err(err) => { - *guard = original; - return Err(err); - } - }; - drop(guard); - if let Err(save_err) = state.save() { - if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) { - return Err(AppError::localized( - "config.save.rollback_failed", - format!("保存配置失败: {save_err};回滚失败: {rollback_err}"), - format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"), - )); - } - return Err(save_err); - } - - if let Some(action) = action { - if let Err(err) = Self::apply_post_commit(state, &action) { - if let Err(rollback_err) = - Self::rollback_after_failure(state, original.clone(), action.backup.clone()) - { - return Err(AppError::localized( - "post_commit.rollback_failed", - format!("后置操作失败: {err};回滚失败: {rollback_err}"), - format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"), - )); - } - return Err(err); - } - } - - Ok(result) - } - - fn restore_config_only(state: &AppState, snapshot: MultiAppConfig) -> Result<(), AppError> { - { - let mut guard = state.config.write().map_err(AppError::from)?; - *guard = snapshot; - } - state.save() - } - - fn rollback_after_failure( - state: &AppState, - snapshot: MultiAppConfig, - backup: LiveSnapshot, - ) -> Result<(), AppError> { - Self::restore_config_only(state, snapshot)?; - backup.restore() - } - - fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> { - Self::write_live_snapshot(&action.app_type, &action.provider)?; - if action.sync_mcp { - // 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用 - use crate::services::mcp::McpService; - McpService::sync_all_enabled(state)?; - } - if action.refresh_snapshot { - Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?; - } - Ok(()) - } - - fn refresh_provider_snapshot( - state: &AppState, - app_type: &AppType, - provider_id: &str, - ) -> Result<(), AppError> { - match app_type { - AppType::Claude => { - let settings_path = get_claude_settings_path(); - if !settings_path.exists() { - return Err(AppError::localized( - "claude.live.missing", - "Claude 设置文件不存在,无法刷新快照", - "Claude settings file missing; cannot refresh snapshot", - )); - } - let mut live_after = read_json_file::(&settings_path)?; - let _ = Self::normalize_claude_models_in_value(&mut live_after); - { - let mut guard = state.config.write().map_err(AppError::from)?; - if let Some(manager) = guard.get_manager_mut(app_type) { - if let Some(target) = manager.providers.get_mut(provider_id) { - target.settings_config = live_after; - } - } - } - state.save()?; - } - AppType::Codex => { - let auth_path = get_codex_auth_path(); - if !auth_path.exists() { - return Err(AppError::localized( - "codex.live.missing", - "Codex auth.json 不存在,无法刷新快照", - "Codex auth.json missing; cannot refresh snapshot", - )); - } - let auth: Value = read_json_file(&auth_path)?; - let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; - - { - let mut guard = state.config.write().map_err(AppError::from)?; - if let Some(manager) = guard.get_manager_mut(app_type) { - if let Some(target) = manager.providers.get_mut(provider_id) { - let obj = target.settings_config.as_object_mut().ok_or_else(|| { - AppError::Config(format!( - "供应商 {provider_id} 的 Codex 配置必须是 JSON 对象" - )) - })?; - obj.insert("auth".to_string(), auth.clone()); - obj.insert("config".to_string(), Value::String(cfg_text.clone())); - } - } - } - state.save()?; - } - AppType::Gemini => { - use crate::gemini_config::{ - env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, - }; - - let env_path = get_gemini_env_path(); - if !env_path.exists() { - return Err(AppError::localized( - "gemini.live.missing", - "Gemini .env 文件不存在,无法刷新快照", - "Gemini .env file missing; cannot refresh snapshot", - )); - } - let env_map = read_gemini_env()?; - let mut live_after = env_to_json(&env_map); - - let settings_path = get_gemini_settings_path(); - let config_value = if settings_path.exists() { - read_json_file(&settings_path)? - } else { - json!({}) - }; - - if let Some(obj) = live_after.as_object_mut() { - obj.insert("config".to_string(), config_value); - } - - { - let mut guard = state.config.write().map_err(AppError::from)?; - if let Some(manager) = guard.get_manager_mut(app_type) { - if let Some(target) = manager.providers.get_mut(provider_id) { - target.settings_config = live_after; - } - } - } - state.save()?; - } - } - Ok(()) - } - - fn capture_live_snapshot(app_type: &AppType) -> Result { + fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { let path = get_claude_settings_path(); - let settings = if path.exists() { - Some(read_json_file::(&path)?) - } else { - None - }; - Ok(LiveSnapshot::Claude { settings }) + write_json_file(&path, &provider.settings_config)?; } AppType::Codex => { + let obj = provider.settings_config.as_object().ok_or_else(|| { + AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()) + })?; + let auth = obj.get("auth").ok_or_else(|| { + AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()) + })?; + let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| { + AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) + })?; + let auth_path = get_codex_auth_path(); + write_json_file(&auth_path, auth)?; let config_path = get_codex_config_path(); - let auth = if auth_path.exists() { - Some(read_json_file::(&auth_path)?) - } else { - None - }; - let config = if config_path.exists() { - Some( - std::fs::read_to_string(&config_path) - .map_err(|e| AppError::io(&config_path, e))?, - ) - } else { - None - }; - Ok(LiveSnapshot::Codex { auth, config }) + std::fs::write(&config_path, config_str) + .map_err(|e| AppError::io(&config_path, e))?; } AppType::Gemini => { - // 新增 use crate::gemini_config::{ - get_gemini_env_path, get_gemini_settings_path, read_gemini_env, + get_gemini_settings_path, json_to_env, write_gemini_env_atomic, }; - let path = get_gemini_env_path(); - let env = if path.exists() { - Some(read_gemini_env()?) - } else { - None - }; - let settings_path = get_gemini_settings_path(); - let config = if settings_path.exists() { - Some(read_json_file(&settings_path)?) - } else { - None - }; - Ok(LiveSnapshot::Gemini { env, config }) + + // Extract env and config from provider settings + let env_value = provider.settings_config.get("env"); + let config_value = provider.settings_config.get("config"); + + // Write env file + if let Some(env) = env_value { + let env_map = json_to_env(env)?; + write_gemini_env_atomic(&env_map)?; + } + + // Write settings file + if let Some(config) = config_value { + let settings_path = get_gemini_settings_path(); + write_json_file(&settings_path, config)?; + } } } + Ok(()) + } + + /// 将数据库中的当前供应商同步到对应 live 配置 + pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> { + for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let current_id = match state.db.get_current_provider(app_type.as_str())? { + Some(id) => id, + None => continue, + }; + let providers = state.db.get_all_providers(app_type.as_str())?; + if let Some(provider) = providers.get(¤t_id) { + Self::write_live_snapshot(&app_type, provider)?; + } else { + log::warn!( + "无法同步 live 配置: 当前供应商 {} ({}) 未找到", + current_id, + app_type.as_str() + ); + } + } + + // MCP 同步 + McpService::sync_all_enabled(state)?; + Ok(()) } /// 列出指定应用下的所有供应商 pub fn list( state: &AppState, app_type: AppType, - ) -> Result, AppError> { - let config = state.config.read().map_err(AppError::from)?; - let manager = config - .get_manager(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - Ok(manager.get_all_providers().clone()) + ) -> Result, AppError> { + state.db.get_all_providers(app_type.as_str()) } /// 获取当前供应商 ID pub fn current(state: &AppState, app_type: AppType) -> Result { - let config = state.config.read().map_err(AppError::from)?; - let manager = config - .get_manager(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - Ok(manager.current.clone()) + state + .db + .get_current_provider(app_type.as_str()) + .map(|opt| opt.unwrap_or_default()) } /// 新增供应商 @@ -747,35 +598,20 @@ impl ProviderService { Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; - let app_type_clone = app_type.clone(); - let provider_clone = provider.clone(); + // 保存到数据库 + state.db.save_provider(app_type.as_str(), &provider)?; - Self::run_transaction(state, move |config| { - config.ensure_app(&app_type_clone); - let manager = config - .get_manager_mut(&app_type_clone) - .ok_or_else(|| Self::app_not_found(&app_type_clone))?; + // 检查是否需要同步(如果是当前供应商,或者没有当前供应商) + let current = state.db.get_current_provider(app_type.as_str())?; + if current.is_none() { + // 如果没有当前供应商,设为当前并同步 + state + .db + .set_current_provider(app_type.as_str(), &provider.id)?; + Self::write_live_snapshot(&app_type, &provider)?; + } - let is_current = manager.current == provider_clone.id; - manager - .providers - .insert(provider_clone.id.clone(), provider_clone.clone()); - - let action = if is_current { - let backup = Self::capture_live_snapshot(&app_type_clone)?; - Some(PostCommitAction { - app_type: app_type_clone.clone(), - provider: provider_clone.clone(), - backup, - sync_mcp: false, - refresh_snapshot: false, - }) - } else { - None - }; - - Ok((true, action)) - }) + Ok(true) } /// 更新供应商 @@ -788,71 +624,30 @@ impl ProviderService { // 归一化 Claude 模型键 Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; - let provider_id = provider.id.clone(); - let app_type_clone = app_type.clone(); - let provider_clone = provider.clone(); - Self::run_transaction(state, move |config| { - let manager = config - .get_manager_mut(&app_type_clone) - .ok_or_else(|| Self::app_not_found(&app_type_clone))?; + // 检查是否为当前供应商 + let current_id = state.db.get_current_provider(app_type.as_str())?; + let is_current = current_id.as_deref() == Some(provider.id.as_str()); - if !manager.providers.contains_key(&provider_id) { - return Err(AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - )); - } + // 保存到数据库 + state.db.save_provider(app_type.as_str(), &provider)?; - let is_current = manager.current == provider_id; - let merged = if let Some(existing) = manager.providers.get(&provider_id) { - let mut updated = provider_clone.clone(); - match (existing.meta.as_ref(), updated.meta.take()) { - // 前端未提供 meta,表示不修改,沿用旧值 - (Some(old_meta), None) => { - updated.meta = Some(old_meta.clone()); - } - (None, None) => { - updated.meta = None; - } - // 前端提供的 meta 视为权威,直接覆盖(其中 custom_endpoints 允许是空,表示删除所有自定义端点) - (_old, Some(new_meta)) => { - updated.meta = Some(new_meta); - } - } - updated - } else { - provider_clone.clone() - }; + if is_current { + Self::write_live_snapshot(&app_type, &provider)?; + // Sync MCP + use crate::services::mcp::McpService; + McpService::sync_all_enabled(state)?; + } - manager.providers.insert(provider_id.clone(), merged); - - let action = if is_current { - let backup = Self::capture_live_snapshot(&app_type_clone)?; - Some(PostCommitAction { - app_type: app_type_clone.clone(), - provider: provider_clone.clone(), - backup, - sync_mcp: false, - refresh_snapshot: false, - }) - } else { - None - }; - - Ok((true, action)) - }) + Ok(true) } /// 导入当前 live 配置为默认供应商 pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> { { - let config = state.config.read().map_err(AppError::from)?; - if let Some(manager) = config.get_manager(&app_type) { - if !manager.get_all_providers().is_empty() { - return Ok(()); - } + let providers = state.db.get_all_providers(app_type.as_str())?; + if !providers.is_empty() { + return Ok(()); } } @@ -926,18 +721,11 @@ impl ProviderService { ); provider.category = Some("custom".to_string()); - { - let mut config = state.config.write().map_err(AppError::from)?; - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - manager - .providers - .insert(provider.id.clone(), provider.clone()); - manager.current = provider.id.clone(); - } + state.db.save_provider(app_type.as_str(), &provider)?; + state + .db + .set_current_provider(app_type.as_str(), &provider.id)?; - state.save()?; Ok(()) } @@ -1010,12 +798,8 @@ impl ProviderService { app_type: AppType, provider_id: &str, ) -> Result, AppError> { - let cfg = state.config.read().map_err(AppError::from)?; - let manager = cfg - .get_manager(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - - let Some(provider) = manager.providers.get(provider_id) else { + let providers = state.db.get_all_providers(app_type.as_str())?; + let Some(provider) = providers.get(provider_id) else { return Ok(vec![]); }; let Some(meta) = provider.meta.as_ref() else { @@ -1046,29 +830,9 @@ impl ProviderService { )); } - { - let mut cfg = state.config.write().map_err(AppError::from)?; - let manager = cfg - .get_manager_mut(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - let provider = manager.providers.get_mut(provider_id).ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - ) - })?; - let meta = provider.meta.get_or_insert_with(ProviderMeta::default); - - let endpoint = CustomEndpoint { - url: normalized.clone(), - added_at: Self::now_millis(), - last_used: None, - }; - meta.custom_endpoints.insert(normalized, endpoint); - } - - state.save()?; + state + .db + .add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?; Ok(()) } @@ -1080,19 +844,9 @@ impl ProviderService { url: String, ) -> Result<(), AppError> { let normalized = url.trim().trim_end_matches('/').to_string(); - - { - let mut cfg = state.config.write().map_err(AppError::from)?; - if let Some(manager) = cfg.get_manager_mut(&app_type) { - if let Some(provider) = manager.providers.get_mut(provider_id) { - if let Some(meta) = provider.meta.as_mut() { - meta.custom_endpoints.remove(&normalized); - } - } - } - } - - state.save()?; + state + .db + .remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?; Ok(()) } @@ -1105,20 +859,16 @@ impl ProviderService { ) -> Result<(), AppError> { let normalized = url.trim().trim_end_matches('/').to_string(); - { - let mut cfg = state.config.write().map_err(AppError::from)?; - if let Some(manager) = cfg.get_manager_mut(&app_type) { - if let Some(provider) = manager.providers.get_mut(provider_id) { - if let Some(meta) = provider.meta.as_mut() { - if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) { - endpoint.last_used = Some(Self::now_millis()); - } - } + // Get provider, update last_used, save back + let mut providers = state.db.get_all_providers(app_type.as_str())?; + if let Some(provider) = providers.get_mut(provider_id) { + if let Some(meta) = provider.meta.as_mut() { + if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) { + endpoint.last_used = Some(Self::now_millis()); + state.db.save_provider(app_type.as_str(), provider)?; } } } - - state.save()?; Ok(()) } @@ -1128,20 +878,15 @@ impl ProviderService { app_type: AppType, updates: Vec, ) -> Result { - { - let mut cfg = state.config.write().map_err(AppError::from)?; - let manager = cfg - .get_manager_mut(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; + let mut providers = state.db.get_all_providers(app_type.as_str())?; - for update in updates { - if let Some(provider) = manager.providers.get_mut(&update.id) { - provider.sort_index = Some(update.sort_index); - } + for update in updates { + if let Some(provider) = providers.get_mut(&update.id) { + provider.sort_index = Some(update.sort_index); + state.db.save_provider(app_type.as_str(), provider)?; } } - state.save()?; Ok(true) } @@ -1222,11 +967,8 @@ impl ProviderService { provider_id: &str, ) -> Result { let (script_code, timeout, api_key, base_url, access_token, user_id) = { - let config = state.config.read().map_err(AppError::from)?; - let manager = config - .get_manager(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| { + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers.get(provider_id).ok_or_else(|| { AppError::localized( "provider.not_found", format!("供应商不存在: {provider_id}"), @@ -1300,98 +1042,7 @@ impl ProviderService { .await } - /// 切换指定应用的供应商 - pub fn switch(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { - let app_type_clone = app_type.clone(); - let provider_id_owned = provider_id.to_string(); - - Self::run_transaction(state, move |config| { - let backup = Self::capture_live_snapshot(&app_type_clone)?; - let provider = match app_type_clone { - AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?, - AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?, - AppType::Gemini => Self::prepare_switch_gemini(config, &provider_id_owned)?, - }; - - let action = PostCommitAction { - app_type: app_type_clone.clone(), - provider, - backup, - sync_mcp: true, // v3.7.0: 所有应用切换时都同步 MCP,防止配置丢失 - refresh_snapshot: true, - }; - - Ok(((), Some(action))) - }) - } - - fn prepare_switch_codex( - config: &mut MultiAppConfig, - provider_id: &str, - ) -> Result { - let provider = config - .get_manager(&AppType::Codex) - .ok_or_else(|| Self::app_not_found(&AppType::Codex))? - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - ) - })?; - - Self::backfill_codex_current(config, provider_id)?; - - if let Some(manager) = config.get_manager_mut(&AppType::Codex) { - manager.current = provider_id.to_string(); - } - - Ok(provider) - } - - fn backfill_codex_current( - config: &mut MultiAppConfig, - next_provider: &str, - ) -> Result<(), AppError> { - let current_id = config - .get_manager(&AppType::Codex) - .map(|m| m.current.clone()) - .unwrap_or_default(); - - if current_id.is_empty() || current_id == next_provider { - return Ok(()); - } - - let auth_path = get_codex_auth_path(); - if !auth_path.exists() { - return Ok(()); - } - - let auth: Value = read_json_file(&auth_path)?; - let config_path = get_codex_config_path(); - let config_text = if config_path.exists() { - std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? - } else { - String::new() - }; - - let live = json!({ - "auth": auth, - "config": config_text, - }); - - if let Some(manager) = config.get_manager_mut(&AppType::Codex) { - if let Some(current) = manager.providers.get_mut(¤t_id) { - current.settings_config = live; - } - } - - Ok(()) - } - + #[allow(dead_code)] fn write_codex_live(provider: &Provider) -> Result<(), AppError> { let settings = provider .settings_config @@ -1412,131 +1063,7 @@ impl ProviderService { Ok(()) } - fn prepare_switch_claude( - config: &mut MultiAppConfig, - provider_id: &str, - ) -> Result { - let provider = config - .get_manager(&AppType::Claude) - .ok_or_else(|| Self::app_not_found(&AppType::Claude))? - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - ) - })?; - - Self::backfill_claude_current(config, provider_id)?; - - if let Some(manager) = config.get_manager_mut(&AppType::Claude) { - manager.current = provider_id.to_string(); - } - - Ok(provider) - } - - fn prepare_switch_gemini( - config: &mut MultiAppConfig, - provider_id: &str, - ) -> Result { - let provider = config - .get_manager(&AppType::Gemini) - .ok_or_else(|| Self::app_not_found(&AppType::Gemini))? - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - ) - })?; - - Self::backfill_gemini_current(config, provider_id)?; - - if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { - manager.current = provider_id.to_string(); - } - - Ok(provider) - } - - fn backfill_claude_current( - config: &mut MultiAppConfig, - next_provider: &str, - ) -> Result<(), AppError> { - let settings_path = get_claude_settings_path(); - if !settings_path.exists() { - return Ok(()); - } - - let current_id = config - .get_manager(&AppType::Claude) - .map(|m| m.current.clone()) - .unwrap_or_default(); - if current_id.is_empty() || current_id == next_provider { - return Ok(()); - } - - let mut live = read_json_file::(&settings_path)?; - let _ = Self::normalize_claude_models_in_value(&mut live); - if let Some(manager) = config.get_manager_mut(&AppType::Claude) { - if let Some(current) = manager.providers.get_mut(¤t_id) { - current.settings_config = live; - } - } - - Ok(()) - } - - fn backfill_gemini_current( - config: &mut MultiAppConfig, - next_provider: &str, - ) -> Result<(), AppError> { - use crate::gemini_config::{ - env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, - }; - - let env_path = get_gemini_env_path(); - if !env_path.exists() { - return Ok(()); - } - - let current_id = config - .get_manager(&AppType::Gemini) - .map(|m| m.current.clone()) - .unwrap_or_default(); - if current_id.is_empty() || current_id == next_provider { - return Ok(()); - } - - let env_map = read_gemini_env()?; - let mut live = env_to_json(&env_map); - - let settings_path = get_gemini_settings_path(); - let config_value = if settings_path.exists() { - read_json_file(&settings_path)? - } else { - json!({}) - }; - if let Some(obj) = live.as_object_mut() { - obj.insert("config".to_string(), config_value); - } - - if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { - if let Some(current) = manager.providers.get_mut(¤t_id) { - current.settings_config = live; - } - } - - Ok(()) - } - + #[allow(dead_code)] fn write_claude_live(provider: &Provider) -> Result<(), AppError> { let settings_path = get_claude_settings_path(); let mut content = provider.settings_config.clone(); @@ -1613,14 +1140,6 @@ impl ProviderService { Ok(()) } - fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { - match app_type { - AppType::Codex => Self::write_codex_live(provider), - AppType::Claude => Self::write_claude_live(provider), - AppType::Gemini => Self::write_gemini_live(provider), // 新增 - } - } - fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { @@ -1838,6 +1357,7 @@ impl ProviderService { } } + #[allow(dead_code)] fn app_not_found(app_type: &AppType) -> AppError { AppError::localized( "provider.app_not_found", @@ -1846,76 +1366,44 @@ impl ProviderService { ) } + /// 删除供应商 + pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + let current = state.db.get_current_provider(app_type.as_str())?; + if current.as_deref() == Some(id) { + return Err(AppError::Message( + "无法删除当前正在使用的供应商".to_string(), + )); + } + state.db.delete_provider(app_type.as_str(), id) + } + + /// 切换供应商 + pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + // Check if provider exists + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers + .get(id) + .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + + // Set current + state.db.set_current_provider(app_type.as_str(), id)?; + + // Sync to live + Self::write_live_snapshot(&app_type, provider)?; + + // Sync MCP + use crate::services::mcp::McpService; + McpService::sync_all_enabled(state)?; + + Ok(()) + } + fn now_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64 } - - pub fn delete(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { - let provider_snapshot = { - let config = state.config.read().map_err(AppError::from)?; - let manager = config - .get_manager(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - - if manager.current == provider_id { - return Err(AppError::localized( - "provider.delete.current", - "不能删除当前正在使用的供应商", - "Cannot delete the provider currently in use", - )); - } - - manager.providers.get(provider_id).cloned().ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id}"), - format!("Provider not found: {provider_id}"), - ) - })? - }; - - match app_type { - AppType::Codex => { - crate::codex_config::delete_codex_provider_config( - provider_id, - &provider_snapshot.name, - )?; - } - AppType::Claude => { - // 兼容旧版本:历史上会在 Claude 目录内为每个供应商生成 settings-*.json 副本 - // 这里继续清理这些遗留文件,避免堆积过期配置。 - let by_name = get_provider_config_path(provider_id, Some(&provider_snapshot.name)); - let by_id = get_provider_config_path(provider_id, None); - delete_file(&by_name)?; - delete_file(&by_id)?; - } - AppType::Gemini => { - // Gemini 使用单一的 .env 文件,不需要删除单独的供应商配置文件 - } - } - - { - let mut config = state.config.write().map_err(AppError::from)?; - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| Self::app_not_found(&app_type))?; - - if manager.current == provider_id { - return Err(AppError::localized( - "provider.delete.current", - "不能删除当前正在使用的供应商", - "Cannot delete the provider currently in use", - )); - } - - manager.providers.remove(provider_id); - } - - state.save() - } } #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 1af2f47b..3a9b05b0 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -231,7 +231,12 @@ impl SkillService { // 解析技能元数据 match self.parse_skill_metadata(&skill_md) { Ok(meta) => { - let directory = path.file_name().unwrap().to_string_lossy().to_string(); + // 安全地获取目录名 + let Some(dir_name) = path.file_name() else { + log::warn!("Failed to get directory name from path: {path:?}"); + continue; + }; + let directory = dir_name.to_string_lossy().to_string(); // 构建 README URL(考虑 skillsPath) let readme_path = if let Some(ref skills_path) = repo.skills_path { @@ -305,7 +310,12 @@ impl SkillService { continue; } - let directory = path.file_name().unwrap().to_string_lossy().to_string(); + // 安全地获取目录名 + let Some(dir_name) = path.file_name() else { + log::warn!("Failed to get directory name from path: {path:?}"); + continue; + }; + let directory = dir_name.to_string_lossy().to_string(); // 更新已安装状态 let mut found = false; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 518e1cb4..d4c8cea7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -2,8 +2,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; +use crate::database::Database; use crate::error::AppError; /// 自定义端点配置 @@ -90,8 +91,7 @@ impl Default for AppSettings { impl AppSettings { fn settings_path() -> PathBuf { - // settings.json 必须使用固定路径,不能被 app_config_dir 覆盖 - // 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中 + // settings.json 保留用于旧版本迁移和无数据库场景 dirs::home_dir() .expect("无法获取用户主目录") .join(".cc-switch") @@ -128,7 +128,7 @@ impl AppSettings { .map(|s| s.to_string()); } - pub fn load() -> Self { + fn load_from_file() -> Self { let path = Self::settings_path(); if let Ok(content) = fs::read_to_string(&path) { match serde_json::from_str::(&content) { @@ -149,26 +149,80 @@ impl AppSettings { Self::default() } } +} - pub fn save(&self) -> Result<(), AppError> { - let mut normalized = self.clone(); - normalized.normalize_paths(); - let path = Self::settings_path(); +fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { + let mut normalized = settings.clone(); + normalized.normalize_paths(); + let path = AppSettings::settings_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } - let json = serde_json::to_string_pretty(&normalized) - .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; - Ok(()) + let json = serde_json::to_string_pretty(&normalized) + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + Ok(()) +} + +static SETTINGS_STORE: OnceLock> = OnceLock::new(); + +fn settings_store() -> &'static RwLock { + SETTINGS_STORE.get_or_init(|| RwLock::new(load_initial_settings())) +} + +static SETTINGS_DB: OnceLock> = OnceLock::new(); +const APP_SETTINGS_KEY: &str = "app_settings"; + +pub fn bind_db(db: Arc) { + if SETTINGS_DB.set(db).is_err() { + return; + } + + if let Some(store) = SETTINGS_STORE.get() { + let mut guard = store.write().expect("写入设置锁失败"); + *guard = load_initial_settings(); } } -fn settings_store() -> &'static RwLock { - static STORE: OnceLock> = OnceLock::new(); - STORE.get_or_init(|| RwLock::new(AppSettings::load())) +fn load_initial_settings() -> AppSettings { + if let Some(db) = SETTINGS_DB.get() { + if let Some(from_db) = load_from_db(db.as_ref()) { + return from_db; + } + + // 从文件迁移一次并写入数据库 + let file_settings = AppSettings::load_from_file(); + if let Err(e) = save_to_db(db.as_ref(), &file_settings) { + log::warn!("迁移设置到数据库失败,将继续使用内存副本: {e}"); + } + return file_settings; + } + + AppSettings::load_from_file() +} + +fn load_from_db(db: &Database) -> Option { + let raw = db.get_setting(APP_SETTINGS_KEY).ok()??; + match serde_json::from_str::(&raw) { + Ok(mut settings) => { + settings.normalize_paths(); + Some(settings) + } + Err(err) => { + log::warn!("解析数据库中 app_settings 失败: {err}"); + None + } + } +} + +fn save_to_db(db: &Database, settings: &AppSettings) -> Result<(), AppError> { + let mut normalized = settings.clone(); + normalized.normalize_paths(); + let json = + serde_json::to_string(&normalized).map_err(|e| AppError::JsonSerialize { source: e })?; + db.set_setting(APP_SETTINGS_KEY, &json) } fn resolve_override_path(raw: &str) -> PathBuf { @@ -195,7 +249,11 @@ pub fn get_settings() -> AppSettings { pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); - new_settings.save()?; + if let Some(db) = SETTINGS_DB.get() { + save_to_db(db, &new_settings)?; + } else { + save_settings_file(&new_settings)?; + } let mut guard = settings_store().write().expect("写入设置锁失败"); *guard = new_settings; diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 4453d97d..1cf26de5 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,26 +1,14 @@ -use crate::app_config::MultiAppConfig; -use crate::error::AppError; -use std::sync::RwLock; +use crate::database::Database; +use std::sync::Arc; /// 全局应用状态 pub struct AppState { - pub config: RwLock, + pub db: Arc, } impl AppState { /// 创建新的应用状态 - /// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。 - pub fn try_new() -> Result { - let config = MultiAppConfig::load()?; - Ok(Self { - config: RwLock::new(config), - }) - } - - /// 保存配置到文件 - pub fn save(&self) -> Result<(), AppError> { - let config = self.config.read().map_err(AppError::from)?; - - config.save() + pub fn new(db: Arc) -> Self { + Self { db } } } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index fb61133a..3cd3e75a 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -254,16 +254,65 @@ export function DeepLinkImportDialog() { - {/* Model (if present) */} - {request.model && ( -
-
- {t("deeplink.model")} -
-
- {request.model} -
-
+ {/* Model Fields - 根据应用类型显示不同的模型字段 */} + {request.app === "claude" ? ( + <> + {/* Claude 四种模型字段 */} + {request.haikuModel && ( +
+
+ {t("deeplink.haikuModel")} +
+
+ {request.haikuModel} +
+
+ )} + {request.sonnetModel && ( +
+
+ {t("deeplink.sonnetModel")} +
+
+ {request.sonnetModel} +
+
+ )} + {request.opusModel && ( +
+
+ {t("deeplink.opusModel")} +
+
+ {request.opusModel} +
+
+ )} + {request.model && ( +
+
+ {t("deeplink.multiModel")} +
+
+ {request.model} +
+
+ )} + + ) : ( + <> + {/* Codex 和 Gemini 使用通用 model 字段 */} + {request.model && ( +
+
+ {t("deeplink.model")} +
+
+ {request.model} +
+
+ )} + )} {/* Notes (if present) */} diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 6f103bd2..f2060094 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -156,13 +156,14 @@ const UsageFooter: React.FC = ({ {t("usage.remaining")} {firstUsage.remaining.toFixed(2)} @@ -310,12 +311,13 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { {t("usage.remaining")} {remaining.toFixed(2)} diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index cbf28490..d867c45d 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -78,6 +78,8 @@ export function EditProviderDialog({ async (values: ProviderFormValues) => { if (!provider) return; + // 注意:values.settingsConfig 已经是最终的配置字符串 + // ProviderForm 已经为不同的 app 类型(Claude/Codex/Gemini)正确组装了配置 const parsedConfig = JSON.parse(values.settingsConfig) as Record< string, unknown diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index 9369fe00..7f936a1e 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,7 +1,14 @@ -import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; +import { + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; -import { RefreshCw } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { RefreshCw, Search } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; @@ -24,6 +31,7 @@ export const SkillsPage = forwardRef( const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); const [repoManagerOpen, setRepoManagerOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { try { @@ -111,12 +119,12 @@ export const SkillsPage = forwardRef( const errorMessage = error instanceof Error ? error.message : String(error); - // 使用错误解析器格式化错误,传入 "skills.uninstallFailed" - const { title, description } = formatSkillError( - errorMessage, - t, - "skills.uninstallFailed", - ); + // 使用错误解析器格式化错误,传入 "skills.uninstallFailed" + const { title, description } = formatSkillError( + errorMessage, + t, + "skills.uninstallFailed", + ); toast.error(title, { description, @@ -162,6 +170,24 @@ export const SkillsPage = forwardRef( await Promise.all([loadRepos(), loadSkills()]); }; + // 过滤技能列表 + const filteredSkills = useMemo(() => { + if (!searchQuery.trim()) return skills; + + const query = searchQuery.toLowerCase(); + return skills.filter((skill) => { + const name = skill.name?.toLowerCase() || ""; + const description = skill.description?.toLowerCase() || ""; + const directory = skill.directory?.toLowerCase() || ""; + + return ( + name.includes(query) || + description.includes(query) || + directory.includes(query) + ); + }); + }, [skills, searchQuery]); + return (
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */} @@ -190,16 +216,49 @@ export const SkillsPage = forwardRef(
) : ( -
- {skills.map((skill) => ( - - ))} -
+ <> + {/* 搜索框 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ {searchQuery && ( +

+ {t("skills.count", { count: filteredSkills.length })} +

+ )} +
+ + {/* 技能列表或无结果提示 */} + {filteredSkills.length === 0 ? ( +
+

+ {t("skills.noResults")} +

+

+ {t("skills.emptyDescription")} +

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ )} + )} diff --git a/src/hooks/useImportExport.ts b/src/hooks/useImportExport.ts index eb569437..be9cb267 100644 --- a/src/hooks/useImportExport.ts +++ b/src/hooks/useImportExport.ts @@ -78,7 +78,7 @@ export function useImportExport( if (!selectedFile) { toast.error( t("settings.selectFileFailed", { - defaultValue: "请选择有效的配置文件", + defaultValue: "请选择有效的 SQL 备份文件", }), ); return; @@ -97,7 +97,7 @@ export function useImportExport( const message = result.message || t("settings.configCorrupted", { - defaultValue: "配置文件已损坏或格式不正确", + defaultValue: "SQL 文件已损坏或格式不正确", }); setErrorMessage(message); toast.error(message); @@ -150,14 +150,14 @@ export function useImportExport( const exportConfig = useCallback(async () => { try { - const defaultName = `cc-switch-config-${ - new Date().toISOString().split("T")[0] - }.json`; + const now = new Date(); + const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const defaultName = `cc-switch-export-${stamp}.sql`; const destination = await settingsApi.saveFileDialog(defaultName); if (!destination) { toast.error( t("settings.selectFileFailed", { - defaultValue: "选择保存位置失败", + defaultValue: "请选择 SQL 备份保存路径", }), ); return; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 17a6f7cf..6276f489 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -145,10 +145,10 @@ "themeLight": "Light", "themeDark": "Dark", "themeSystem": "System", - "importExport": "Import/Export Config", - "importExportHint": "Import or export CC Switch configuration for backup or migration.", - "exportConfig": "Export Config to File", - "selectConfigFile": "Select Config File", + "importExport": "SQL Import/Export", + "importExportHint": "Import or export database SQL backups for migration or restore.", + "exportConfig": "Export SQL Backup", + "selectConfigFile": "Select SQL File", "noFileSelected": "No configuration file selected.", "import": "Import", "importing": "Importing...", @@ -159,8 +159,8 @@ "importPartialHint": "Please manually reselect the provider to refresh the live configuration.", "configExported": "Config exported to:", "exportFailed": "Export failed", - "selectFileFailed": "Failed to select file", - "configCorrupted": "Config file may be corrupted or invalid", + "selectFileFailed": "Please choose a valid SQL backup file", + "configCorrupted": "SQL file may be corrupted or invalid", "backupId": "Backup ID", "autoReload": "Data will refresh automatically in 2 seconds...", "languageOptionChinese": "中文", @@ -735,7 +735,10 @@ "removeSuccess": "Repository {{owner}}/{{name}} removed", "removeFailed": "Failed to remove", "skillCount": "{{count}} skills detected" - } + }, + "search": "Search Skills", + "searchPlaceholder": "Search skill name or description...", + "noResults": "No matching skills found" }, "deeplink": { "confirmImport": "Confirm Import Provider", @@ -746,6 +749,10 @@ "endpoint": "API Endpoint", "apiKey": "API Key", "model": "Model", + "haikuModel": "Haiku Model", + "sonnetModel": "Sonnet Model", + "opusModel": "Opus Model", + "multiModel": "Multi-Modal Model", "notes": "Notes", "import": "Import", "importing": "Importing...", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 59faf396..91c82799 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -145,10 +145,10 @@ "themeLight": "浅色", "themeDark": "深色", "themeSystem": "跟随系统", - "importExport": "导入导出配置", - "importExportHint": "导入导出 CC Switch 配置,便于备份或迁移。", - "exportConfig": "导出配置到文件", - "selectConfigFile": "选择配置文件", + "importExport": "SQL 导入导出", + "importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。", + "exportConfig": "导出 SQL 备份", + "selectConfigFile": "选择 SQL 文件", "noFileSelected": "尚未选择配置文件。", "import": "导入", "importing": "导入中...", @@ -159,8 +159,8 @@ "importPartialHint": "请手动重新选择一次供应商以刷新对应配置。", "configExported": "配置已导出到:", "exportFailed": "导出失败", - "selectFileFailed": "选择文件失败", - "configCorrupted": "配置文件可能已损坏或格式不正确", + "selectFileFailed": "请选择有效的 SQL 备份文件", + "configCorrupted": "SQL 文件可能已损坏或格式不正确", "backupId": "备份ID", "autoReload": "数据将在2秒后自动刷新...", "languageOptionChinese": "中文", @@ -735,7 +735,10 @@ "removeSuccess": "仓库 {{owner}}/{{name}} 已删除", "removeFailed": "删除失败", "skillCount": "识别到 {{count}} 个技能" - } + }, + "search": "搜索技能", + "searchPlaceholder": "搜索技能名称或描述...", + "noResults": "未找到匹配的技能" }, "deeplink": { "confirmImport": "确认导入供应商配置", @@ -746,6 +749,10 @@ "endpoint": "API 端点", "apiKey": "API 密钥", "model": "模型", + "haikuModel": "Haiku 模型", + "sonnetModel": "Sonnet 模型", + "opusModel": "Opus 模型", + "multiModel": "多模态模型", "notes": "备注", "import": "导入", "importing": "导入中...",