174 Commits

Author SHA1 Message Date
KevIsDev
67702202af Revert "feat: diff-view-v2-no-conflict" 2025-02-21 18:23:48 +00:00
KevIsDev
bffb8a2a90 Revert "Merge pull request #1335 from Toddyclipsgg/diff-view-v2"
This reverts commit 871aefbe83, reversing
changes made to 8c72ed76b3.
2025-02-21 15:01:09 +00:00
KevIsDev
871aefbe83 Merge pull request #1335 from Toddyclipsgg/diff-view-v2
feat: diff-view-v2-no-conflict
2025-02-21 14:39:55 +00:00
Stijnus
8c72ed76b3 Merge pull request #1356 from Stijnus/ACT_BoltDYI_UI_BUGFIX
fix: for remove settings icon _index.tsx
2025-02-21 10:34:25 +01:00
Toddyclipsgg
c24e69718e Merge branch 'main' into diff-view-v2 2025-02-21 00:37:50 -03:00
Stijnus
aa02448516 Update _index.tsx
Removed the settings button in the landingpage.
2025-02-20 13:03:59 +01:00
KevIsDev
220e2da7ec fix: preserve complete provider settings in cookies
Previously only the enabled state was being saved to cookies, causing loss of provider configuration like baseURL.
2025-02-20 03:27:15 +00:00
Stijnus
097dffdd78 Merge pull request #1342 from Stijnus/ACT_FEAT_BoltDYI_UI_BUGFIX
fix: bolt dyi UI bugfix
2025-02-18 21:55:50 +01:00
Stijnus
ca7f5ad26b update local models 2025-02-18 21:51:02 +01:00
Stijnus
10af7c9835 Default settings feature tab 2025-02-18 17:50:01 +01:00
Stijnus
7f3b5f6628 Merge branch 'main' into ACT_FEAT_BoltDYI_UI_BUGFIX 2025-02-18 16:30:36 +01:00
Stijnus
0e60d9cca8 UI bug fixes 2025-02-18 14:13:13 +01:00
KevIsDev
0e2e1834e7 Merge pull request #1322 from kamilfurtak/model-search
feat: implement llm model search
2025-02-18 03:47:47 +00:00
Leex
0fd039bdb3 update: new anthropogenic model for amazon bedrock
feat: New Claude 3.5 Sonnet v2 Anthropogenic Model for amazon bedrock
2025-02-17 23:23:23 +01:00
Toddyclipsgg
382bf2c9a3 feat: Add Diff View and File History Tracking
- Implemented a new Diff view in the Workbench to track file changes
- Added file history tracking with version control and change tracking
- Created a FileModifiedDropdown to browse and manage modified files
- Enhanced ActionRunner to support file history persistence
- Updated Workbench and BaseChat components to support new diff view functionality
- Added support for inline and side-by-side diff view modes
2025-02-16 23:10:15 -03:00
KevIsDev
70b723d514 fix: debounce profile update notifications to prevent toast spam
a new toast was being triggered for every character input.
2025-02-17 01:49:33 +00:00
Toddyclipsgg
744b6c2433 Merge branch 'stackblitz-labs:main' into main 2025-02-16 21:28:20 -03:00
Stijnus
6e89710ec7 Several UI fixes 2025-02-15 17:28:17 +01:00
Kamil Furtak
e717d25191 Add click outside handler to close ModelSelector dropdown 2025-02-15 16:59:58 +01:00
Kamil Furtak
2056625cbd Enhance accessibility for ModelSelector dropdown
Added keyboard support for toggling the dropdown using Enter or Space keys, improving accessibility. Also set appropriate focus properties by adding tabindex to the combobox element.
2025-02-15 16:55:55 +01:00
Kamil Furtak
f94be5b383 remove transparency 2025-02-15 16:50:15 +01:00
Kamil Furtak
a3a06d03ba fix truncation 2025-02-15 16:42:40 +01:00
Kamil Furtak
95de84c41a add whitespace-nowrap 2025-02-15 16:15:36 +01:00
Kamil Furtak
0f6bfca9bc remove truncate 2025-02-15 16:07:33 +01:00
Kamil Furtak
24bf34c683 fix: Size of dropdowns should be always the same and not break into 2 lines 2025-02-15 15:59:37 +01:00
Stijnus
db5f30e1ee Update settings.ts 2025-02-15 15:12:08 +01:00
Kamil Furtak
cb58db3bf0 fix ui add keyboard events 2025-02-15 13:51:11 +01:00
Kamil Furtak
d56708966a fix srollbar 2025-02-15 13:45:26 +01:00
Kamil Furtak
b25db9b27e add model search 2025-02-15 13:37:35 +01:00
Leex
294adfdd1b fix: bug fix New UI / Feature tab - Default values hard-coded
It was not possible to change "Context Optimization" and "Prompt Library".
2025-02-15 09:57:22 +01:00
Stijnus
823c66e05c Update pnpm-lock.yaml 2025-02-15 00:26:00 +01:00
Stijnus
79ea72ad74 Update FeaturesTab.tsx
Bug fix : Preserve Settings FeatureTab
2025-02-15 00:24:25 +01:00
Filipe Giácomo
4b817ebdce Update amazon-bedrock.ts
New Claude 3.5 Sonnet v2 Anthropogenic Model
2025-02-12 11:39:37 -03:00
Anirban Kar
a0ea69fd74 fix: auto scroll fix, scroll allow user to scroll up during ai response (#1299) 2025-02-12 01:11:14 +05:30
Anirban Kar
2fe1f1d443 fix: starter template icons fix and auto resize of custon icons are reverted (#1298)
* fix: starter template icons fix and auto resize of custon icons are reverted

* fix: slidev icon revert
2025-02-12 01:08:28 +05:30
BaptisteCDC
c88938cffc ci: updated Dockerfile to install latest version of corepack to ensure to have the right version to pnpm 2025-02-11 19:45:41 +05:30
Cole Medin
fbf1d46106 Merge pull request #1245 from Stijnus/FEAT_BoltDYI_NEW_SETTINGS_UI_V3
feat: bolt dyi new settings UI V3
2025-02-10 15:44:25 -06:00
Toddyclipsgg
6412e58b35 Delete .tool-versions 2025-02-09 18:28:14 -03:00
Toddyclipsgg
5ef7bdc42e Delete wrangler.toml 2025-02-09 18:27:29 -03:00
Leex
6a8449e6aa fix: removed chrome canary note
canary is not needed anymore.
2025-02-09 19:19:06 +01:00
Stijnus
ba582536e4 Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_NEW_SETTINGS_UI_V3 2025-02-07 17:29:05 +01:00
Stijnus
b5096735ef Update fix
more enhanced  UI and more details what is fixed,  ect
2025-02-03 01:40:54 +01:00
Stijnus
f091409f7e Avatar Fix , control pannel UI fix 2025-02-03 01:04:23 +01:00
Stijnus
f3468d495d Update AvatarDropdown.tsx 2025-02-02 23:50:15 +01:00
Stijnus
23d253e7f8 fixes as fequestd 2025-02-02 18:52:17 +01:00
Stijnus
07435fc255 update fixes BETA 2025-02-02 17:06:37 +01:00
Stijnus
d479550c49 Update api.update.ts 2025-02-02 16:58:41 +01:00
Stijnus
ee67bf1e29 Update UpdateTab.tsx 2025-02-02 16:55:12 +01:00
Stijnus
c200e2f74d update fixes 2025-02-02 16:50:43 +01:00
Stijnus
f50ebc6ea7 Update api.update.ts 2025-02-02 16:42:36 +01:00
Stijnus
3dd3fafb77 Update api.update.ts 2025-02-02 16:25:22 +01:00
Stijnus
9171cf48aa bug fix and some icons changes 2025-02-02 16:17:33 +01:00
Stijnus
8035a76429 Bug fix for the Keyboard Shortcuts
MAC OS SHORTCUTS:
- Toggle Terminal: ⌘ + `
- Toggle Theme: ⌘ + ⌥ + ⇧ + D
- Toggle Chat: ⌘ + ⌥ + J
- Toggle Settings: ⌘ + ⌥ + S

WINDOWS/LINUX SHORTCUTS:
- Toggle Terminal: Ctrl + `
- Toggle Theme: Win + Alt + Shift + D
- Toggle Chat: Ctrl + Alt + J
- Toggle Settings: Ctrl + Alt + S
2025-02-02 15:51:56 +01:00
Stijnus
84f45dd041 Add support for export JSON, CSV, PDF, Text
## Changes to DebugTab.tsx & EventLogsTab.tsx

### Debug Tab Enhancements
- Added multi-page support for PDF exports
- Implemented proper page breaks and content flow
- Added styled headers, key-value pairs, and horizontal lines
- Added title and timestamp at the top of the PDF
- Improved PDF layout with sections for system info, web app info, and performance metrics
- Added footer with page numbers
- Fixed memory usage calculations with proper null checks
- Added error handling for undefined values

### Event Logs Tab Enhancements
- Added comprehensive PDF export functionality with:
  - Professional header with bolt.diy branding
  - Report summary section
  - Log statistics with color-coded categories
  - Detailed log entries with proper formatting
  - Multi-page support with proper page breaks
  - Footer with page numbers and timestamp
- Added multiple export formats (JSON, CSV, PDF, Text)
- Fixed linter errors and improved type safety
- Enhanced dark mode compatibility
2025-02-02 13:25:04 +01:00
Stijnus
fc3dd8c84c Final UI V3
# UI V3 Changelog

Major updates and improvements in this release:

## Core Changes
- Complete NEW REWRITTEN UI system overhaul (V3) with semantic design tokens
- New settings management system with drag-and-drop capabilities
- Enhanced provider system supporting multiple AI services
- Improved theme system with better dark mode support
- New component library with consistent design patterns

## Technical Updates
- Reorganized project architecture for better maintainability
- Performance optimizations and bundle size improvements
- Enhanced security features and access controls
- Improved developer experience with better tooling
- Comprehensive testing infrastructure

## New Features
- Background rays effect for improved visual feedback
- Advanced tab management system
- Automatic and manual update support
- Enhanced error handling and visualization
- Improved accessibility across all components

For detailed information about all changes and improvements, please see the full changelog.
2025-02-02 01:42:30 +01:00
Stijnus
999d87b1e8 beta New control panel
# Tab Management System Implementation

## What's Been Implemented
1. Complete Tab Management System with:
   - Drag and drop functionality for reordering tabs
   - Visual feedback during drag operations
   - Smooth animations and transitions
   - Dark mode support
   - Search functionality for tabs
   - Reset to defaults option

2. Developer Mode Features:
   - Shows ALL available tabs in developer mode
   - Maintains tab order across modes
   - Proper visibility toggles
   - Automatic inclusion of developer-specific tabs

3. User Mode Features:
   - Shows only user-configured tabs
   - Maintains separate tab configurations
   - Proper visibility management

## Key Components
- `TabManagement.tsx`: Main management interface
- `ControlPanel.tsx`: Main panel with tab display
- Integration with tab configuration store
- Proper type definitions and interfaces

## Technical Features
- React DnD for drag and drop
- Framer Motion for animations
- TypeScript for type safety
- UnoCSS for styling
- Toast notifications for user feedback

## Next Steps
1. Testing:
   - Test tab visibility in both modes
   - Verify drag and drop persistence
   - Check dark mode compatibility
   - Verify search functionality
   - Test reset functionality

2. Potential Improvements:
   - Add tab grouping functionality
   - Implement tab pinning
   - Add keyboard shortcuts
   - Improve accessibility
   - Add tab descriptions
   - Add tab icons customization

3. Documentation:
   - Add inline code comments
   - Create user documentation
   - Document API interfaces
   - Add setup instructions

4. Future Features:
   - Tab export/import
   - Custom tab creation
   - Tab templates
   - User preferences sync
   - Tab statistics

## Known Issues to Address
1. Ensure all tabs are visible in developer mode
2. Improve drag and drop performance
3. Better state persistence
4. Enhanced error handling
5. Improved type safety

## Usage Instructions
1. Switch to developer mode to see all available tabs
2. Use drag and drop to reorder tabs
3. Toggle visibility using switches
4. Use search to filter tabs
5. Reset to defaults if needed

## Technical Debt
1. Refactor tab configuration store
2. Improve type definitions
3. Add proper error boundaries
4. Implement proper loading states
5. Add comprehensive testing

## Security Considerations
1. Validate tab configurations
2. Sanitize user input
3. Implement proper access control
4. Add audit logging
5. Secure state management
2025-02-01 18:01:34 +01:00
Anirban Kar
3be18e3f9d feat: added dynamic model support for openAI provider (#1241) 2025-02-01 15:29:54 +05:30
Stijnus
af620d0197 Bug Fixes part1 2025-01-31 12:55:52 +01:00
Stijnus
5791dafcd1 Update uno.config.ts 2025-01-30 23:57:57 +01:00
Stijnus
ec1bcb8aaa Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_NEW_SETTINGS_UI_V2 2025-01-30 23:43:27 +01:00
Stijnus
73bc81c830 Update constants.ts 2025-01-30 23:40:53 +01:00
Stijnus
cf909431f2 test 2025-01-30 23:28:21 +01:00
Stijnus
2c991e42da revert 2025-01-30 23:05:04 +01:00
Stijnus
1eae44dd14 fix landingpage icons 2025-01-30 22:50:42 +01:00
Stijnus
fab0cdd04e fix icons landingpage 2025-01-30 22:11:07 +01:00
Stijnus
0c6f36302e Taskmanager update
Enhanced System Metrics:
Detailed CPU metrics including temperature and frequency
Comprehensive memory breakdown with heap usage
Advanced performance metrics (FPS, page load times, web vitals)
Detailed network statistics
Storage monitoring with visual indicators
Battery health and detailed status
Power Management:
Multiple power profiles (Performance, Balanced, Power Saver)
Enhanced energy saver mode
Automatic power management based on system state
Detailed energy savings statistics
System Health:
Overall system health score
Real-time issue detection and alerts
Performance optimization suggestions
Historical metrics tracking
UI Improvements:
Interactive graphs for all metrics
Color-coded status indicators
Detailed tooltips and explanations
Collapsible sections for better organization
Alert system for critical events
Performance Monitoring:
Frame rate monitoring
Resource usage tracking
Network performance analysis
Web vitals monitoring
Detailed timing metrics
To use the enhanced task manager:
Monitor system health in the new health score section
Choose a power profile based on your needs
Enable auto energy saver for automatic power management
Monitor real-time alerts for system issues
5. View detailed metrics in each category
Check optimization suggestions when performance issues arise
2025-01-30 21:08:23 +01:00
Stijnus
d1d23d80e7 big fixes
fixes feedback from thecodacus
2025-01-30 17:17:36 +01:00
Stijnus
d9a380f28a Service console check providers 2025-01-30 01:58:47 +01:00
Anirban Kar
137e268943 fix: tune the system prompt to avoid diff writing (#1218) 2025-01-30 01:22:29 +05:30
Anirban Kar
f5fbf421e9 fix: issue with alternate message when importing from folder and git (#1216) 2025-01-29 22:16:58 +05:30
Stijnus
9e8d05cb54 Merge branch 'FEAT_BoltDYI_CHAT_FIX' into FEAT_BoltDYI_NEW_SETTINGS_UI_V2 2025-01-29 17:29:18 +01:00
Stijnus
547cde78c0 Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_NEW_SETTINGS_UI_V2 2025-01-29 14:37:07 +01:00
Stijnus
d966656070 Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_CHAT_FIX 2025-01-29 13:37:56 +01:00
Anirban Kar
7016111906 feat: enhanced Code Context and Project Summary Features (#1191)
* fix: docker prod env variable fix

* lint and typecheck

* removed hardcoded tag

* better summary generation

* improved  summary generation for context optimization

* remove think tags from the generation
2025-01-29 15:37:20 +05:30
Stijnus
89a38603d5 Update Messages.client.tsx
Fix for chat response auto scroll :

### Benefits
- More reliable auto-scrolling during streaming
- Better handling of user scroll interactions
- Improved mobile device support
- Smoother initial load with instant scroll
- Cleaner code organization and maintainability

### Testing
The changes can be verified by:
1. Loading chat - should instantly scroll to bottom
2. Streaming messages - should smoothly auto-scroll
3. Manual scroll up - should stay at user's position
4. Scroll to bottom - should resume auto-scroll
5. Mobile testing - should work with touch interactions
2025-01-29 01:17:54 +01:00
Stijnus
24688c3104 Update TaskManagerTab.tsx 2025-01-29 00:02:46 +01:00
Stijnus
3c8b9d107b Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_NEW_SETTINGS_UI_V2 2025-01-28 23:00:04 +01:00
Stijnus
0765bc3173 add install ollama models , fixes 2025-01-28 22:57:06 +01:00
Anirban Kar
a199295ad8 feat: support for <think></think> tags to allow reasoning tokens formatted in UI (#1205) 2025-01-29 02:33:53 +05:30
Anirban Kar
32bfdd9c24 feat: added more dynamic models, sorted and remove duplicate models (#1206) 2025-01-29 02:33:23 +05:30
Mohammad Saif Khan
39a0724ef3 feat: add Gemini 2.0 Flash-thinking-exp-01-21 model with 65k token support (#1202)
Added the new gemini-2.0-flash-thinking-exp-01-21 model to the GoogleProvider's static model configuration. This model supports a significantly increased maxTokenAllowed limit of 65,536 tokens, enabling it to handle larger context windows compared to existing Gemini models (previously capped at 8k tokens). The model is labeled as "Gemini 2.0 Flash-thinking-exp-01-21" for clear identification in the UI/dropdowns.
2025-01-28 23:30:50 +05:30
Stijnus
f32016c91d Github enhancement 2025-01-28 13:21:24 +01:00
Stijnus
387516b7fd fix 2025-01-28 11:41:33 +01:00
Stijnus
c4c73622f5 Fix ESLint issues 2025-01-28 11:39:12 +01:00
Stijnus
58d3853cd6 Merge branch 'main' into FEAT_BoltDYI_NEW_SETTINGS_UI_V2 2025-01-28 10:38:06 +01:00
Stijnus
0db9ce2717 Revert "Major UI improvements"
This reverts commit 6e52114172.
2025-01-28 10:28:45 +01:00
Stijnus
6e52114172 Major UI improvements 2025-01-28 01:33:19 +01:00
Mohammad Saif Khan
68bbbd0a67 feat: add deepseek-r1-distill-llama-70b to groq provider (#1187)
This PR introduces a new model, deepseek-r1-distill-llama-70b, to the staticModels array and ensures compatibility with the Groq API. The changes include:

Adding the deepseek-r1-distill-llama-70b model to the staticModels array with its relevant metadata.

Updating the Groq API call to use the new model for chat completions.

These changes enable the application to support the deepseek-r1-distill-llama-70b model, expanding the range of available models for users.
2025-01-27 18:08:46 +05:30
Anirban Kar
bbae032a37 fix: git import issue when importing bolt on bolt (#1020)
* fix: import bolt on bolt fix

* added escape on folder import

* type fix
2025-01-27 18:05:55 +05:30
Anirban Kar
6d4196a2b4 fix: improve push to github option (#1111)
* feat: better push to githubbutton

* added url update on push to github
2025-01-27 17:58:25 +05:30
Anirban Kar
df766c98d4 feat: added support for reasoning content (#1168) 2025-01-25 16:16:19 +05:30
Anirban Kar
660353360f fix: docker prod env variable fix (#1170)
* fix: docker prod env variable fix

* lint and typecheck

* removed hardcoded tag
2025-01-25 03:52:26 +05:30
Stijnus
afb1e44187 Update ConnectionsTab.tsx 2025-01-24 17:05:27 +01:00
Stijnus
b0fe1fc871 update 2025-01-24 16:51:43 +01:00
Stijnus
7378d7598b Update DebugTab.tsx 2025-01-24 16:16:05 +01:00
Stijnus
027f6529f2 UI Enhancements 2025-01-24 16:14:48 +01:00
Stijnus
505f1db071 Connection improvements 2025-01-24 01:23:17 +01:00
Stijnus
56783ae45a Merge branch 'main' into FEAT_BoltDYI_NEW_SETTINGS_UI 2025-01-24 01:17:34 +01:00
Stijnus
2ba230cafa Delete app/components/settings/CHANGELOG.md 2025-01-24 01:15:40 +01:00
Stijnus
c1f4f2feb6 Update ConnectionsTab.tsx 2025-01-24 01:13:05 +01:00
Stijnus
27eab591a9 UI fixes 2025-01-24 01:08:51 +01:00
Stijnus
4e6f18ea1e Update ConnectionsTab.tsx 2025-01-23 17:49:22 +01:00
Stijnus
fd98059cfe connection github enhancements 2025-01-23 16:02:50 +01:00
github-actions[bot]
5a0489f3c3 chore: release version 0.0.6 2025-01-22 20:54:02 +00:00
Anirban Kar
3c56346e83 feat: enhance context handling by adding code context selection and implementing summary generation (#1091) #release
* feat: add context annotation types and enhance file handling in LLM processing

* feat: enhance context handling by adding chatId to annotations and implementing summary generation

* removed useless changes

* feat: updated token counts to include optimization requests

* prompt fix

* logging added

* useless logs removed
2025-01-22 22:48:13 +05:30
Stijnus
5a6c0e5f90 Update .gitignore 2025-01-22 15:26:32 +01:00
Stijnus
b99ab7ad27 final UI fixes 2025-01-22 15:25:55 +01:00
Stijnus
308f36316a Update UpdateTab.tsx 2025-01-22 14:54:11 +01:00
Stijnus
a286e3bf34 ui fix 2025-01-22 14:47:58 +01:00
Stijnus
2b585c24fe ui dark mode enhancements 2025-01-22 14:34:30 +01:00
Leex
2ae897aae7 fix: get environment variables for docker #1120
fix: get environment variables for docker
2025-01-22 13:01:10 +01:00
Stijnus
723c6a4f02 fixes 2025-01-22 02:05:18 +01:00
Anirban Kar
46f15bdde6 fix: updated system prompt to have correct indentations (#1139)
* updated system prompt to have correct indentations

* removed a section
2025-01-22 01:59:07 +05:30
Anirban Kar
0ad4aa56d3 feat: added deepseek reasoner model in deepseek provider (#1151) 2025-01-22 01:58:31 +05:30
Anirban Kar
f29380e147 fix: auto select starter template bugfix (#1148) 2025-01-22 00:30:31 +05:30
Stijnus
a94330e4a4 fixes 2025-01-21 16:45:54 +01:00
Stijnus
6d98affc3d Add new features
Bolt DIY UI

## New User Interface Features

### 🎨 Redesigned Control Panel

The Bolt DIY interface has been completely redesigned with a modern, intuitive layout featuring two main components:

1. **Users Window** - Main control panel for regular users
2. **Developer Window** - Advanced settings and debugging tools

### 💡 Core Features

- **Drag & Drop Tab Management**: Customize tab order in both User and Developer windows
- **Dynamic Status Updates**: Real-time status indicators for updates, notifications, and system health
- **Responsive Design**: Beautiful transitions and animations using Framer Motion
- **Dark/Light Mode Support**: Full theme support with consistent styling
- **Improved Accessibility**: Using Radix UI primitives for better accessibility
- **Enhanced Provider Management**: Split view for local and cloud providers
- **Resource Monitoring**: New Task Manager for system performance tracking

### 🎯 Tab Overview

#### User Window Tabs

1. **Profile**

   - Manage user profile and account settings
   - Avatar customization
   - Account preferences

2. **Settings**

   - Configure application preferences
   - Customize UI behavior
   - Manage general settings

3. **Notifications**

   - Real-time notification center
   - Unread notification tracking
   - Notification preferences

4. **Features**

   - Explore new and upcoming features
   - Feature preview toggles
   - Early access options

5. **Data**

   - Data management tools
   - Storage settings
   - Backup and restore options

6. **Cloud Providers**

   - Configure cloud-based AI providers
   - API key management
   - Cloud model selection
   - Provider-specific settings
   - Status monitoring for each provider

7. **Local Providers**

   - Manage local AI models
   - Ollama integration and model updates
   - LM Studio configuration
   - Local inference settings
   - Model download and updates

8. **Task Manager**

   - System resource monitoring
   - Process management
   - Performance metrics
   - Resource usage graphs
   - Alert configurations

9. **Connection**

   - Network status monitoring
   - Connection health metrics
   - Troubleshooting tools
   - Latency tracking
   - Auto-reconnect settings

10. **Debug**

- System diagnostics
- Performance monitoring
- Error tracking
- Provider status checks
- System information

11. **Event Logs**

- Comprehensive system logs
- Filtered log views
- Log management tools
- Error tracking
- Performance metrics

12. **Update**
    - Version management
    - Update notifications
    - Release notes
    - Auto-update configuration

#### Developer Window Enhancements

- **Advanced Tab Management**

  - Fine-grained control over tab visibility
  - Custom tab ordering
  - Tab permission management
  - Category-based organization

- **Developer Tools**
  - Enhanced debugging capabilities
  - System metrics and monitoring
  - Performance optimization tools
  - Advanced logging features

### 🚀 UI Improvements

1. **Enhanced Navigation**

   - Intuitive back navigation
   - Breadcrumb-style header
   - Context-aware menu system
   - Improved tab organization

2. **Status Indicators**

   - Dynamic update badges
   - Real-time connection status
   - System health monitoring
   - Provider status tracking

3. **Profile Integration**

   - Quick access profile menu
   - Avatar support
   - Fast settings access
   - Personalization options

4. **Accessibility Features**
   - Keyboard navigation
   - Screen reader support
   - Focus management
   - ARIA attributes

### 🛠 Technical Enhancements

- **State Management**

  - Nano Stores for efficient state handling
  - Persistent settings storage
  - Real-time state synchronization
  - Provider state management

- **Performance Optimizations**

  - Lazy loading of tab contents
  - Efficient DOM updates
  - Optimized animations
  - Resource monitoring

- **Developer Experience**
  - Improved error handling
  - Better debugging tools
  - Enhanced logging system
  - Performance profiling

### 🎯 Future Roadmap

- [ ] Additional customization options
- [ ] Enhanced theme support
- [ ] More developer tools
- [ ] Extended API integrations
- [ ] Advanced monitoring capabilities
- [ ] Custom provider plugins
- [ ] Enhanced resource management
- [ ] Advanced debugging features

## 🔧 Technical Details

### Dependencies

- Radix UI for accessible components
- Framer Motion for animations
- React DnD for drag and drop
- Nano Stores for state management

### Browser Support

- Modern browsers (Chrome, Firefox, Safari, Edge)
- Progressive enhancement for older browsers

### Performance

- Optimized bundle size
- Efficient state updates
- Minimal re-renders
- Resource-aware operations

## 📝 Contributing

We welcome contributions! Please see our contributing guidelines for more information.

## 📄 License

MIT License - see LICENSE for details
2025-01-21 15:18:17 +01:00
Stijnus
293fdb7377 Create changelogUI.md 2025-01-21 12:22:12 +01:00
Stijnus
afc26dd96a Update changelog.md 2025-01-21 12:20:29 +01:00
Stijnus
8c89aa61c4 Update changelog.md 2025-01-21 12:17:31 +01:00
Stijnus
78d4e1bb54 ui fix 2025-01-21 11:55:26 +01:00
Stijnus
436a8e54bf ui refactor 2025-01-20 09:53:15 +01:00
Leex
d62e211d09 Merge pull request #1122 from Stijnus/FEAT_BoltDYI_PREVIEW_V3
fix: for Open preview in a new tab.
2025-01-19 00:17:13 +01:00
Leex
031e679aff Merge branch 'main' into FEAT_BoltDYI_PREVIEW_V3 2025-01-19 00:14:09 +01:00
Leex
6ae1ac25d2 Merge pull request #1094 from lewis617/patch-1
docs: replace docker-compose with docker compose
2025-01-19 00:11:39 +01:00
Leex
b842e0c920 Merge pull request #1124 from stackblitz-labs/leex279-patch-readme-changes-v1
docs: update README.md
2025-01-18 23:41:05 +01:00
Stijnus
48f4999f32 Update Preview.tsx 2025-01-18 21:45:29 +01:00
Leex
840dd59af8 docs: Update README.md 2025-01-18 21:24:02 +01:00
Leex
7f540b5d1e Update README.md
- Enhanced text for bolt.diy docs section and better visibility to guide people there instead using github readme which is more for devs
- added NodeJS based applications, as this is not clear and some people asked about in the community
2025-01-18 21:14:57 +01:00
Stijnus
b732f20233 bug fix for Open preview in a new tab. 2025-01-18 19:25:01 +01:00
Ken Jenney
7341b1292b Get environment variables from .env.local
Environment variables are not being passed to the container in the development profile. Adding env_file to pass them so they can be used by the application.
2025-01-18 12:38:34 -05:00
Stijnus
9230ef3b55 Merge branch 'stackblitz-labs:main' into FEAT_BoltDYI_NEW_SETTINGS_UI 2025-01-18 13:49:03 +01:00
Oliver Jägle
e19644268c feat: configure dynamic providers via .env (#1108)
* Use backend API route to fetch dynamic models

# Conflicts:
#	app/components/chat/BaseChat.tsx

* Override ApiKeys if provided in frontend

* Remove obsolete artifact

* Transport api keys from client to server in header

* Cache static provider information

* Restore reading provider settings from cookie

* Reload only a single provider on api key change

* Transport apiKeys and providerSettings via cookies.

While doing this, introduce a simple helper function for cookies
2025-01-18 03:39:19 +05:30
Stijnus
87ff81035f feat: added the "Open Preview in a New Tab" (#1101)
* added the "Open Preview in a New Tab"

* enhancement

[Open Preview] [▼]  // Two buttons side by side
    |
    +-- [Mobile (375x667)]    // Dropdown menu
    |-- [Tablet (768x1024)]
    |-- [Laptop (1366x768)]
    +-- [Desktop (1920x1080)]

* Update Preview.tsx

* Update Preview.tsx
2025-01-18 01:55:44 +05:30
Stijnus
8f3f37ae7e fix 2025-01-17 19:55:07 +01:00
Stijnus
f33ba635e8 V1 : Release of the new Settings Dashboard
# 🚀 Release v1.0.0

## What's Changed 🌟

### 🎨 UI/UX Improvements
- **Dark Mode Support**
  - Implemented comprehensive dark theme across all components
  - Enhanced contrast and readability in dark mode
  - Added smooth theme transitions
  - Optimized dialog overlays and backdrops

### 🛠️ Settings Panel
- **Data Management**
  - Added chat history export/import functionality
  - Implemented settings backup and restore
  - Added secure data deletion with confirmations
  - Added profile customization options

- **Provider Management**
  - Added comprehensive provider configuration
  - Implemented URL-configurable providers
  - Added local model support (Ollama, LMStudio)
  - Added provider health checks
  - Added provider status indicators

- **Ollama Integration**
  - Added Ollama Model Manager with real-time updates
  - Implemented model version tracking
  - Added bulk update capability
  - Added progress tracking for model updates
  - Displays model details (parameter size, quantization)

- **GitHub Integration**
  - Added GitHub connection management
  - Implemented secure token storage
  - Added connection state persistence
  - Real-time connection status updates
  - Proper error handling and user feedback

### 📊 Event Logging
- **System Monitoring**
  - Added real-time event logging system
  - Implemented log filtering by type (info, warning, error, debug)
  - Added log export functionality
  - Added auto-scroll and search capabilities
  - Enhanced log visualization with color coding

### 💫 Animations & Interactions
- Added smooth page transitions
- Implemented loading states with spinners
- Added micro-interactions for better feedback
- Enhanced button hover and active states
- Added motion effects for UI elements

### 🔐 Security Features
- Secure token storage
- Added confirmation dialogs for destructive actions
- Implemented data validation
- Added file size and type validation
- Secure connection management

### ️ Accessibility
- Improved keyboard navigation
- Enhanced screen reader support
- Added ARIA labels and descriptions
- Implemented focus management
- Added proper dialog accessibility

### 🎯 Developer Experience
- Added comprehensive debug information
- Implemented system status monitoring
- Added version control integration
- Enhanced error handling and reporting
- Added detailed logging system

---

## 🔧 Technical Details
- **Frontend Stack**
  - React 18 with TypeScript
  - Framer Motion for animations
  - TailwindCSS for styling
  - Radix UI for accessible components

- **State Management**
  - Local storage for persistence
  - React hooks for state
  - Custom stores for global state

- **API Integration**
  - GitHub API integration
  - Ollama API integration
  - Provider API management
  - Error boundary implementation

## 📝 Notes
- Initial release focusing on core functionality and user experience
- Enhanced dark mode support across all components
- Improved accessibility and keyboard navigation
- Added comprehensive logging and debugging tools
- Implemented robust error handling and user feedback
2025-01-17 19:33:20 +01:00
Ngô Tấn Tài
c7738243ca feat: added Github provider (#1109) 2025-01-17 13:22:51 +05:30
lewis liu
41bb909f8d fix: fallback model name not working (#1095)
Co-authored-by: 刘一奇 <liuyiqi02@corp.netease.com>
2025-01-15 16:36:33 +05:30
lewis liu
9958496468 fix: replace docker-compose with docker compose 2025-01-15 17:47:48 +08:00
Digitl Alchemyst
85d864f607 Merge pull request #809 from stackblitz-labs/807-transparency-about-development
docs: document how we work
2025-01-12 23:34:48 -06:00
Anirban Kar
fad41973e2 fix: api-key manager cleanup and log error on llm call (#1077)
* fix: api-key manager cleanup and log error on llm call

* log improved
2025-01-13 04:21:29 +05:30
Eduard Ruzga
3a298f1586 fix: cors issues from preview fixed by changing embedder policies (#1056) 2025-01-12 21:58:06 +05:30
GaryStimson
6aaff63ca7 fix: bugfix in fetching API Key on base llm provider. (#1063) 2025-01-12 21:54:45 +05:30
Digitl Alchemyst
12c6b7abe5 Update README.md
Add Todo
2025-01-10 16:38:37 -06:00
Digitl Alchemyst
42030e1e20 Merge pull request #1026 from dnielsen/patch-1
chore: Update README.md with new model integration todo
2025-01-10 11:51:25 -06:00
Adithyan K
49bb17886a fix: added ui indicator on how apikeys are set (UI/Env) for api-key-manager component (#732)
* fixed #333

* Added instruction in case api-key is not set.

* addressed some of the review changes:

1. moved function definiton to useCallback.
2. added a cache to store the env key status and the api call is made only on a cache miss.

* Manages the API-key entered via UI in a better way.

- Persist API keys in cookies when entered via UI
- Automatically load saved keys when switching between providers
- Preserve existing functionality for environment variable based keys

* Re-used map from utils/constants file.

* Code cleanup -  Removed redundant API key init in BaseChat as its already handled by APIKeyManager component.
2025-01-10 21:26:31 +05:30
Leex
6bf36a915c docs: bugfix/formatting faq docs (#1027)
* fixed formatting of faq and enhanced styling

* save

* did further improvements and fixing the sidebar

---------

Co-authored-by: Anirban Kar <thecodacus@gmail.com>
2025-01-10 21:17:32 +05:30
Oliver Jägle
05eca7cde6 Add feature issue template 2025-01-07 19:53:41 +01:00
Oliver Jägle
a0596bbad3 Fix typos in epic template 2025-01-07 19:53:31 +01:00
Oliver Jägle
c6254b7940 Remove bolt.new references from bug template 2025-01-07 19:53:23 +01:00
Oliver Jägle
8d1cc49140 Add epic template 2025-01-07 19:53:15 +01:00
Oliver Jägle
c78995fee2 Clarify PRs in project organization 2025-01-07 19:53:04 +01:00
Oliver Jägle
bc3274c77e Satisfy the linter 2025-01-07 19:52:50 +01:00
Oliver Jägle
f20fff1443 WIP: Add information about development process 2025-01-07 19:52:28 +01:00
Siddharth V
4fd5040355 feat: enhance chat import with multi-format support (#936)
* feat: enhance chat import with multi-format support

- Add support for importing chats from different formats:
  - Standard Bolt format
  - Chrome extension format
  - History array format
  - Bolt export format
- Add Import Chats button to Data Management
- Add proper error handling and logging
- Update README with backup/restore feature

* refactor: simplify chat import formats

- Remove multi-format support from DataTab
- Keep only standard Bolt export formats
- Simplify ImportButtons to handle standard format only
2025-01-07 16:46:42 +02:00
Dave Nielsen
3aed93aeed Update README.md
Added  Granite Integration
2025-01-06 11:21:13 -08:00
Anirban Kar
7004c897f7 updated docs (#1025) 2025-01-07 00:43:35 +05:30
Cole Medin
441b797f16 Adding resources page to index.md for docs. 2025-01-06 10:20:43 -06:00
Cole Medin
354f416559 Updating README with resources and small fixes. 2025-01-06 10:15:19 -06:00
Anirban Kar
6f524fdf27 ci: added arm64 platform for docker published images (#1021) 2025-01-06 19:19:40 +05:30
Anirban Kar
78eb3a5f34 fix: streaming issue fixed for build versions (#1006)
* fix: streaming issue fixed for build versions

* added keep-alive header
2025-01-06 19:18:52 +05:30
Anirban Kar
49c7129ded fix: ollama and lm studio url issue fix for docker and build (#1008)
* fix: ollama and lm studio url issue fix for docker and build

* vite config fix
2025-01-06 19:18:42 +05:30
kunjabijukchhe
3ecac25a35 feat: implement Claude 3, Claude3.5, Nova Pro, Nova Lite and Mistral model integration with AWS Bedrock (#974)
* feat: Integrate AWS Bedrock with Claude 3.5 Sonnet, Claude 3 Sonnet, and Claude 3.5 Haiku

* update Dockerfile for AWS Bedrock configuration

* feat: add new Bedrock model 'Mistral' and update Haiku to version 3

* feat: add new bedrock model Nova Lite and Nova Pro

* Update README documentation to reflect the latest changes

* Add the icon for aws bedrock

* add support for serialized AWS Bedrock configuration in api key
2025-01-06 17:49:16 +05:30
Anirban Kar
7e39e924e1 reverted visibility change (#1018) 2025-01-06 14:05:01 +05:30
Anirban Kar
f2546f12d8 ci: added visibility change to public for docker image publish (#1017) 2025-01-06 13:57:16 +05:30
twsl
77f8a368a2 ci: fix docker image workflow permissions (#1013)
* Update docker.yaml

* Fix indentation in docker workflow file
2025-01-06 10:34:31 +05:30
twsl
02a3abd892 ci: docker Image creation pipeline (#1011)
* Create docker.yaml

* Add build target

* Use build target var

* Use github token instead
2025-01-06 03:32:36 +05:30
Siddarth
6437ceb5dd fix: added XAI to docker config (#274)
* commit

* Create .env.example

* Update docker-compose.yaml

---------

Co-authored-by: Anirban Kar <thecodacus@gmail.com>
2025-01-06 03:18:07 +05:30
Cole Medin
481268b941 docs: updating copyright in LICENSE (#796) 2025-01-06 03:10:26 +05:30
Anirban Kar
59cae5733d fix: git private clone with custom proxy (#1010)
* cookie fix

* fix: git private clone with custom proxy

* list -fix
2025-01-06 01:54:55 +05:30
Eduard Ruzga
b1f9380c30 fix: introduce our own cors proxy for git import to fix 403 errors on isometric git cors proxy (#924)
* Exploration of improving git import

* Fix our own git proxy

* Clean out file counting for progress, does not seem to work well anyways
2025-01-05 17:26:02 +05:30
Eduard Ruzga
31e03ce99f Merge pull request #961 from Gaurav-Wankhede/main
fix: updated hyperbolic link
2025-01-01 19:43:33 +02:00
Gaurav-Wankhede
e9852bfb22 Update hyperbolic.ts
Changed updated Hyperbolic Settings link
2025-01-01 17:59:11 +05:30
Anirban Kar
d2ba8d3be3 fix: show warning on starter template failure and continue (#960) 2025-01-01 13:39:09 +05:30
209 changed files with 26869 additions and 5719 deletions

157
.cursorrules Normal file
View File

@@ -0,0 +1,157 @@
# Project Overview
bolt.diy (previously oTToDev) is an open-source AI-powered full-stack web development platform that allows users to choose different LLM providers for coding assistance. The project supports multiple AI providers including OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, and Groq.
# Personality
- Professional and technically precise
- Focus on best practices and clean code
- Provide clear explanations for code changes
- Maintain consistent code style with the existing codebase
# Techstack
- Framework: Remix
- Runtime: Node.js (>=18.18.0)
- Package Manager: pnpm
- UI: React with TypeScript
- Styling: UnoCSS
- Development Environment: Vite
- Testing: Vitest
- Deployment: Cloudflare Pages
- Containerization: Docker
- Code Quality: ESLint, Prettier, TypeScript
# our .env file
- Follow .env.example for required environment variables
- Keep API keys and sensitive data in .env.local
- Never commit .env files (they are gitignored)
# Error Fixing Process
1. Identify the root cause through error messages and logs
2. Check relevant components and dependencies
3. Verify type safety and TypeScript compliance
4. Test changes locally before committing
5. Follow existing error handling patterns
# Our Codebase
- Main application code in /app directory
- Components follow a modular structure
- Server-side code in app/lib/.server
- Client-side utilities in app/lib/
- Type definitions in types/ directory
- Documentation in docs/ directory
# Current File Structure
- /app - Main application code
- /docs - Documentation
- /functions - Serverless functions
- /public - Static assets
- /scripts - Build and utility scripts
- /types - TypeScript definitions
- /icons - SVG icons and assets
# github upload process
1. Follow conventional commit messages
2. Run linting and tests before committing
3. Create feature branches for new work
4. Submit PRs with clear descriptions
5. Ensure CI/CD checks pass
# Important
- Keep dependencies updated
- Follow TypeScript strict mode
- Maintain backward compatibility
- Document API changes
- Test cross-browser compatibility
# comments
- Use JSDoc for function documentation
- Keep comments clear and concise
- Document complex logic and business rules
- Update comments when changing code
- Remove redundant comments
- Always write comments that are relevant to the code they describe
- Ensure comments explain the "why" not just the "what"
# code review
- Check for type safety
- Verify error handling
- Ensure code follows project patterns
- Look for performance implications
- Validate accessibility standards
# code writing
- Follow TypeScript best practices
- Use functional components for React
- Implement proper error boundaries
- Write testable code
- Follow the DRY principle
# code refactoring
- Maintain backward compatibility
- Update tests alongside changes
- Document breaking changes
- Follow the project's type system
- Keep components modular and reusable
# Development Process
- Write 3 reasoning paragraphs before implementing solutions
- Analyze the problem space thoroughly before jumping to conclusions
- Consider all edge cases and potential impacts
- Process tasks with a Senior Developer mindset
- Continue working until the solution is complete and verified
- Remember and consider the full commit/change history when working
# Code Quality Guidelines
- Fewer lines of code is better, but not at the expense of readability
- Preserve existing comments and documentation
- Add meaningful comments explaining complex logic or business rules
- Follow the principle of "Clean Code, Clear Intent"
- Balance between conciseness and maintainability
- Think twice, code once - avoid premature optimization
- Never add comments just for the sake of commenting - ensure they add value
# Problem Solving Approach
1. Understand the context fully before making changes
2. Document your reasoning and assumptions
3. Consider alternative approaches and their trade-offs
4. Validate your solution against existing patterns
5. Test thoroughly before considering work complete
6. Review impact on related components
# UI GUIDELINES
- Use consistent colors and typography
- Ensure UI is responsive and accessible
- Provide clear feedback for user actions
- Use meaningful icons and labels
- Keep UI clean and organized
- Use consistent spacing and alignment
- Use consistent naming conventions for components and variables
- Use consistent file and folder structure
- Use consistent naming conventions for components and variables
- Use consistent file and folder structure
# Style Guide
- Use consistent naming conventions for components and variables
- Use consistent file and folder structure
- Respect the Light/Dark mode
- Don't use white background for dark mode
- Don't use white text on white background for dark mode
- Match the style of the existing codebase
- Use consistent naming conventions for components and variables

View File

@@ -83,6 +83,17 @@ XAI_API_KEY=
# You only need this environment variable set if you want to use Perplexity models # You only need this environment variable set if you want to use Perplexity models
PERPLEXITY_API_KEY= PERPLEXITY_API_KEY=
# Get your AWS configuration
# https://console.aws.amazon.com/iam/home
# The JSON should include the following keys:
# - region: The AWS region where Bedrock is available.
# - accessKeyId: Your AWS access key ID.
# - secretAccessKey: Your AWS secret access key.
# - sessionToken (optional): Temporary session token if using an IAM role or temporary credentials.
# Example JSON:
# {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey", "sessionToken": "yourSessionToken"}
AWS_BEDROCK_CONFIG=
# Include this environment variable if you want more logging for debugging locally # Include this environment variable if you want more logging for debugging locally
VITE_LOG_LEVEL=debug VITE_LOG_LEVEL=debug

View File

@@ -1,4 +1,4 @@
name: "Bug report" name: 'Bug report'
description: Create a report to help us improve description: Create a report to help us improve
body: body:
- type: markdown - type: markdown
@@ -6,8 +6,8 @@ body:
value: | value: |
Thank you for reporting an issue :pray:. Thank you for reporting an issue :pray:.
This issue tracker is for bugs and issues found with [Bolt.new](https://bolt.new). This issue tracker is for bugs and issues found with [Bolt.diy](https://bolt.diy).
If you experience issues related to WebContainer, please file an issue in our [WebContainer repo](https://github.com/stackblitz/webcontainer-core), or file an issue in our [StackBlitz core repo](https://github.com/stackblitz/core) for issues with StackBlitz. If you experience issues related to WebContainer, please file an issue in the official [StackBlitz WebContainer repo](https://github.com/stackblitz/webcontainer-core).
The more information you fill in, the better we can help you. The more information you fill in, the better we can help you.
- type: textarea - type: textarea

23
.github/ISSUE_TEMPLATE/epic.md vendored Normal file
View File

@@ -0,0 +1,23 @@
---
name: Epic
about: Epics define long-term vision and capabilities of the software. They will never be finished but serve as umbrella for features.
title: ''
labels:
- epic
assignees: ''
---
# Strategic Impact
<!-- Why does this area matter? How is it integrated into the product or the development process? What would happen if we ignore it? -->
# Target Audience
<!-- Who benefits most from improvements in this area?
Usual values: Software Developers using the IDE | Contributors -->
# Capabilities
<!-- which existing capabilities or future features can be imagined that belong to this epic? This list serves as illustration to sketch the boundaries of this epic.
Once features are actually being planned / described in detail, they can be linked here. -->

28
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View File

@@ -0,0 +1,28 @@
---
name: Feature
about: A pretty vague description of how a capability of our software can be added or improved.
title: ''
labels:
- feature
assignees: ''
---
# Motivation
<!-- What capability should be either established or improved? How is life of the target audience better after it's been done? -->
# Scope
<!-- This is kind-of the definition-of-done for a feature.
Try to keep the scope as small as possible and prefer creating multiple, small features which each solve a single problem / make something better
-->
# Options
<!-- If you already have an idea how this can be implemented, please describe it here.
This allows potential other contributors to join forces and provide meaningful feedback prio to even starting work on it.
-->
# Related
<!-- Link to the epic or other issues or PRs which are related to this feature. -->

81
.github/workflows/docker.yaml vendored Normal file
View File

@@ -0,0 +1,81 @@
---
name: Docker Publish
on:
workflow_dispatch:
push:
branches:
- main
tags:
- v*
- '*'
permissions:
packages: write
contents: read
env:
REGISTRY: ghcr.io
DOCKER_IMAGE: ghcr.io/${{ github.repository }}
BUILD_TARGET: bolt-ai-production # bolt-ai-development
jobs:
docker-build-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- id: string
uses: ASzc/change-string-case-action@v6
with:
string: ${{ env.DOCKER_IMAGE }}
- name: Docker meta
id: meta
uses: crazy-max/ghaction-docker-meta@v5
with:
images: ${{ steps.string.outputs.lowercase }}
flavor: |
latest=true
prefix=
suffix=
tags: |
type=semver,pattern={{version}}
type=pep440,pattern={{version}}
type=ref,event=tag
type=raw,value={{sha}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: ${{ env.BUILD_TARGET }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
cache-to: type=inline
- name: Check manifest
run: |
docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }}
- name: Dump context
if: always()
uses: crazy-max/ghaction-dump-context@v2

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
paths: paths:
- 'docs/**' # This will only trigger the workflow when files in docs directory change - 'docs/**' # This will only trigger the workflow when files in docs directory change
permissions: permissions:
contents: write contents: write
jobs: jobs:
@@ -23,7 +23,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: 3.x python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
key: mkdocs-material-${{ env.cache_id }} key: mkdocs-material-${{ env.cache_id }}
@@ -32,4 +32,4 @@ jobs:
mkdocs-material- mkdocs-material-
- run: pip install mkdocs-material - run: pip install mkdocs-material
- run: mkdocs gh-deploy --force - run: mkdocs gh-deploy --force

View File

@@ -9,10 +9,10 @@ on:
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Validate PR Labels - name: Validate PR Labels
run: | run: |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then
@@ -28,4 +28,4 @@ jobs:
fi fi
else else
echo "This PR doesn't have the stable-release label. No release will be created." echo "This PR doesn't have the stable-release label. No release will be created."
fi fi

View File

@@ -29,4 +29,4 @@ jobs:
docs docs
refactor refactor
revert revert
test test

View File

@@ -2,8 +2,8 @@ name: Mark Stale Issues and Pull Requests
on: on:
schedule: schedule:
- cron: '0 2 * * *' # Runs daily at 2:00 AM UTC - cron: '0 2 * * *' # Runs daily at 2:00 AM UTC
workflow_dispatch: # Allows manual triggering of the workflow workflow_dispatch: # Allows manual triggering of the workflow
jobs: jobs:
stale: stale:
@@ -14,12 +14,12 @@ jobs:
uses: actions/stale@v8 uses: actions/stale@v8
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days." stale-issue-message: 'This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.'
stale-pr-message: "This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days." stale-pr-message: 'This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.'
days-before-stale: 10 # Number of days before marking an issue or PR as stale days-before-stale: 10 # Number of days before marking an issue or PR as stale
days-before-close: 4 # Number of days after being marked stale before closing days-before-close: 4 # Number of days after being marked stale before closing
stale-issue-label: "stale" # Label to apply to stale issues stale-issue-label: 'stale' # Label to apply to stale issues
stale-pr-label: "stale" # Label to apply to stale pull requests stale-pr-label: 'stale' # Label to apply to stale pull requests
exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale exempt-issue-labels: 'pinned,important' # Issues with these labels won't be marked stale
exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale exempt-pr-labels: 'pinned,important' # PRs with these labels won't be marked stale
operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits

View File

@@ -7,12 +7,12 @@ on:
permissions: permissions:
contents: write contents: write
jobs: jobs:
prepare-release: prepare-release:
if: contains(github.event.head_commit.message, '#release') if: contains(github.event.head_commit.message, '#release')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -80,7 +80,6 @@ jobs:
NEW_VERSION=${{ steps.bump_version.outputs.new_version }} NEW_VERSION=${{ steps.bump_version.outputs.new_version }}
pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version
- name: Prepare changelog script - name: Prepare changelog script
run: chmod +x .github/scripts/generate-changelog.sh run: chmod +x .github/scripts/generate-changelog.sh
@@ -89,14 +88,14 @@ jobs:
env: env:
NEW_VERSION: ${{ steps.bump_version.outputs.new_version }} NEW_VERSION: ${{ steps.bump_version.outputs.new_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/scripts/generate-changelog.sh run: .github/scripts/generate-changelog.sh
- name: Get the latest commit hash and version tag - name: Get the latest commit hash and version tag
run: | run: |
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "NEW_VERSION=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_ENV echo "NEW_VERSION=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_ENV
- name: Commit and Tag Release - name: Commit and Tag Release
run: | run: |
git pull git pull
@@ -123,4 +122,4 @@ jobs:
gh release create "$VERSION" \ gh release create "$VERSION" \
--title "Release $VERSION" \ --title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.content }}" \ --notes "${{ steps.changelog.outputs.content }}" \
--target stable --target stable

7
.gitignore vendored
View File

@@ -39,4 +39,9 @@ modelfiles
site site
# commit file ignore # commit file ignore
app/commit.json app/commit.json
changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
.cursorrules
*.md

View File

@@ -6,15 +6,15 @@ Welcome! This guide provides all the details you need to contribute effectively
## 📋 Table of Contents ## 📋 Table of Contents
1. [Code of Conduct](#code-of-conduct) 1. [Code of Conduct](#code-of-conduct)
2. [How Can I Contribute?](#how-can-i-contribute) 2. [How Can I Contribute?](#how-can-i-contribute)
3. [Pull Request Guidelines](#pull-request-guidelines) 3. [Pull Request Guidelines](#pull-request-guidelines)
4. [Coding Standards](#coding-standards) 4. [Coding Standards](#coding-standards)
5. [Development Setup](#development-setup) 5. [Development Setup](#development-setup)
6. [Testing](#testing) 6. [Testing](#testing)
7. [Deployment](#deployment) 7. [Deployment](#deployment)
8. [Docker Deployment](#docker-deployment) 8. [Docker Deployment](#docker-deployment)
9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration) 9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration)
--- ---
@@ -27,60 +27,67 @@ This project is governed by our **Code of Conduct**. By participating, you agree
## 🛠️ How Can I Contribute? ## 🛠️ How Can I Contribute?
### 1⃣ Reporting Bugs or Feature Requests ### 1⃣ Reporting Bugs or Feature Requests
- Check the [issue tracker](#) to avoid duplicates. - Check the [issue tracker](#) to avoid duplicates.
- Use issue templates (if available). - Use issue templates (if available).
- Provide detailed, relevant information and steps to reproduce bugs. - Provide detailed, relevant information and steps to reproduce bugs.
### 2⃣ Code Contributions ### 2⃣ Code Contributions
1. Fork the repository.
2. Create a feature or fix branch. 1. Fork the repository.
3. Write and test your code. 2. Create a feature or fix branch.
3. Write and test your code.
4. Submit a pull request (PR). 4. Submit a pull request (PR).
### 3⃣ Join as a Core Contributor ### 3⃣ Join as a Core Contributor
Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7). Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
--- ---
## ✅ Pull Request Guidelines ## ✅ Pull Request Guidelines
### PR Checklist ### PR Checklist
- Branch from the **main** branch.
- Update documentation, if needed.
- Test all functionality manually.
- Focus on one feature/bug per PR.
### Review Process - Branch from the **main** branch.
1. Manual testing by reviewers. - Update documentation, if needed.
2. At least one maintainer review required. - Test all functionality manually.
3. Address review comments. - Focus on one feature/bug per PR.
### Review Process
1. Manual testing by reviewers.
2. At least one maintainer review required.
3. Address review comments.
4. Maintain a clean commit history. 4. Maintain a clean commit history.
--- ---
## 📏 Coding Standards ## 📏 Coding Standards
### General Guidelines ### General Guidelines
- Follow existing code style.
- Comment complex logic. - Follow existing code style.
- Keep functions small and focused. - Comment complex logic.
- Keep functions small and focused.
- Use meaningful variable names. - Use meaningful variable names.
--- ---
## 🖥️ Development Setup ## 🖥️ Development Setup
### 1⃣ Initial Setup ### 1⃣ Initial Setup
- Clone the repository:
- Clone the repository:
```bash ```bash
git clone https://github.com/stackblitz-labs/bolt.diy.git git clone https://github.com/stackblitz-labs/bolt.diy.git
``` ```
- Install dependencies: - Install dependencies:
```bash ```bash
pnpm install pnpm install
``` ```
- Set up environment variables: - Set up environment variables:
1. Rename `.env.example` to `.env.local`. 1. Rename `.env.example` to `.env.local`.
2. Add your API keys: 2. Add your API keys:
```bash ```bash
GROQ_API_KEY=XXX GROQ_API_KEY=XXX
@@ -88,23 +95,26 @@ Interested in maintaining and growing the project? Fill out our [Contributor App
OPENAI_API_KEY=XXX OPENAI_API_KEY=XXX
... ...
``` ```
3. Optionally set: 3. Optionally set:
- Debug level: `VITE_LOG_LEVEL=debug` - Debug level: `VITE_LOG_LEVEL=debug`
- Context size: `DEFAULT_NUM_CTX=32768` - Context size: `DEFAULT_NUM_CTX=32768`
**Note**: Never commit your `.env.local` file to version control. Its already in `.gitignore`. **Note**: Never commit your `.env.local` file to version control. Its already in `.gitignore`.
### 2⃣ Run Development Server ### 2⃣ Run Development Server
```bash ```bash
pnpm run dev pnpm run dev
``` ```
**Tip**: Use **Google Chrome Canary** for local testing. **Tip**: Use **Google Chrome Canary** for local testing.
--- ---
## 🧪 Testing ## 🧪 Testing
Run the test suite with: Run the test suite with:
```bash ```bash
pnpm test pnpm test
``` ```
@@ -113,10 +123,12 @@ pnpm test
## 🚀 Deployment ## 🚀 Deployment
### Deploy to Cloudflare Pages ### Deploy to Cloudflare Pages
```bash ```bash
pnpm run deploy pnpm run deploy
``` ```
Ensure you have required permissions and that Wrangler is configured. Ensure you have required permissions and that Wrangler is configured.
--- ---
@@ -127,67 +139,76 @@ This section outlines the methods for deploying the application using Docker. Th
--- ---
### 🧑‍💻 Development Environment ### 🧑‍💻 Development Environment
#### Build Options #### Build Options
**Option 1: Helper Scripts**
**Option 1: Helper Scripts**
```bash ```bash
# Development build # Development build
npm run dockerbuild npm run dockerbuild
``` ```
**Option 2: Direct Docker Build Command** **Option 2: Direct Docker Build Command**
```bash ```bash
docker build . --target bolt-ai-development docker build . --target bolt-ai-development
``` ```
**Option 3: Docker Compose Profile** **Option 3: Docker Compose Profile**
```bash ```bash
docker-compose --profile development up docker compose --profile development up
``` ```
#### Running the Development Container #### Running the Development Container
```bash ```bash
docker run -p 5173:5173 --env-file .env.local bolt-ai:development docker run -p 5173:5173 --env-file .env.local bolt-ai:development
``` ```
--- ---
### 🏭 Production Environment ### 🏭 Production Environment
#### Build Options #### Build Options
**Option 1: Helper Scripts**
**Option 1: Helper Scripts**
```bash ```bash
# Production build # Production build
npm run dockerbuild:prod npm run dockerbuild:prod
``` ```
**Option 2: Direct Docker Build Command** **Option 2: Direct Docker Build Command**
```bash ```bash
docker build . --target bolt-ai-production docker build . --target bolt-ai-production
``` ```
**Option 3: Docker Compose Profile** **Option 3: Docker Compose Profile**
```bash ```bash
docker-compose --profile production up docker compose --profile production up
``` ```
#### Running the Production Container #### Running the Production Container
```bash ```bash
docker run -p 5173:5173 --env-file .env.local bolt-ai:production docker run -p 5173:5173 --env-file .env.local bolt-ai:production
``` ```
--- ---
### Coolify Deployment ### Coolify Deployment
For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify): For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify):
1. Import your Git repository into Coolify.
2. Choose **Docker Compose** as the build pack. 1. Import your Git repository into Coolify.
3. Configure environment variables (e.g., API keys). 2. Choose **Docker Compose** as the build pack.
4. Set the start command: 3. Configure environment variables (e.g., API keys).
4. Set the start command:
```bash ```bash
docker compose --profile production up docker compose --profile production up
``` ```
@@ -200,20 +221,22 @@ The `docker-compose.yaml` configuration is compatible with **VS Code Dev Contain
### Steps to Use Dev Containers ### Steps to Use Dev Containers
1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS). 1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS).
2. Select **Dev Containers: Reopen in Container**. 2. Select **Dev Containers: Reopen in Container**.
3. Choose the **development** profile when prompted. 3. Choose the **development** profile when prompted.
4. VS Code will rebuild the container and open it with the pre-configured environment. 4. VS Code will rebuild the container and open it with the pre-configured environment.
--- ---
## 🔑 Environment Variables ## 🔑 Environment Variables
Ensure `.env.local` is configured correctly with: Ensure `.env.local` is configured correctly with:
- API keys.
- Context-specific configurations. - API keys.
- Context-specific configurations.
Example for the `DEFAULT_NUM_CTX` variable:
Example for the `DEFAULT_NUM_CTX` variable:
```bash ```bash
DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM
``` ```

View File

@@ -6,6 +6,8 @@ WORKDIR /app
# Install dependencies (this step is cached as long as the dependencies don't change) # Install dependencies (this step is cached as long as the dependencies don't change)
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN npm install -g corepack@latest
RUN corepack enable pnpm && pnpm install RUN corepack enable pnpm && pnpm install
# Copy the rest of your app's source code # Copy the rest of your app's source code
@@ -25,8 +27,10 @@ ARG ANTHROPIC_API_KEY
ARG OPEN_ROUTER_API_KEY ARG OPEN_ROUTER_API_KEY
ARG GOOGLE_GENERATIVE_AI_API_KEY ARG GOOGLE_GENERATIVE_AI_API_KEY
ARG OLLAMA_API_BASE_URL ARG OLLAMA_API_BASE_URL
ARG XAI_API_KEY
ARG TOGETHER_API_KEY ARG TOGETHER_API_KEY
ARG TOGETHER_API_BASE_URL ARG TOGETHER_API_BASE_URL
ARG AWS_BEDROCK_CONFIG
ARG VITE_LOG_LEVEL=debug ARG VITE_LOG_LEVEL=debug
ARG DEFAULT_NUM_CTX ARG DEFAULT_NUM_CTX
@@ -38,16 +42,19 @@ ENV WRANGLER_SEND_METRICS=false \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \ OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \ GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \ OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
XAI_API_KEY=${XAI_API_KEY} \
TOGETHER_API_KEY=${TOGETHER_API_KEY} \ TOGETHER_API_KEY=${TOGETHER_API_KEY} \
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \ TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
AWS_BEDROCK_CONFIG=${AWS_BEDROCK_CONFIG} \
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \ VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}\
RUNNING_IN_DOCKER=true
# Pre-configure wrangler to disable metrics # Pre-configure wrangler to disable metrics
RUN mkdir -p /root/.config/.wrangler && \ RUN mkdir -p /root/.config/.wrangler && \
echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json
RUN npm run build RUN pnpm run build
CMD [ "pnpm", "run", "dockerstart"] CMD [ "pnpm", "run", "dockerstart"]
@@ -62,6 +69,7 @@ ARG ANTHROPIC_API_KEY
ARG OPEN_ROUTER_API_KEY ARG OPEN_ROUTER_API_KEY
ARG GOOGLE_GENERATIVE_AI_API_KEY ARG GOOGLE_GENERATIVE_AI_API_KEY
ARG OLLAMA_API_BASE_URL ARG OLLAMA_API_BASE_URL
ARG XAI_API_KEY
ARG TOGETHER_API_KEY ARG TOGETHER_API_KEY
ARG TOGETHER_API_BASE_URL ARG TOGETHER_API_BASE_URL
ARG VITE_LOG_LEVEL=debug ARG VITE_LOG_LEVEL=debug
@@ -74,10 +82,13 @@ ENV GROQ_API_KEY=${GROQ_API_KEY} \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \ OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \ GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \ OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
XAI_API_KEY=${XAI_API_KEY} \
TOGETHER_API_KEY=${TOGETHER_API_KEY} \ TOGETHER_API_KEY=${TOGETHER_API_KEY} \
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \ TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
AWS_BEDROCK_CONFIG=${AWS_BEDROCK_CONFIG} \
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \ VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}\
RUNNING_IN_DOCKER=true
RUN mkdir -p ${WORKDIR}/run RUN mkdir -p ${WORKDIR}/run
CMD pnpm run dev --host CMD pnpm run dev --host

24
FAQ.md
View File

@@ -12,6 +12,7 @@ For the best experience with bolt.diy, we recommend using the following models:
- **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements - **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements
**Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt! **Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt!
</details> </details>
<details> <details>
@@ -21,20 +22,21 @@ For the best experience with bolt.diy, we recommend using the following models:
Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences. Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences.
- **Use the enhance prompt icon**: - **Use the enhance prompt icon**:
Before sending your prompt, click the *enhance* icon to let the AI refine your prompt. You can edit the suggested improvements before submitting. Before sending your prompt, click the _enhance_ icon to let the AI refine your prompt. You can edit the suggested improvements before submitting.
- **Scaffold the basics first, then add features**: - **Scaffold the basics first, then add features**:
Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on. Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on.
- **Batch simple instructions**: - **Batch simple instructions**:
Combine simple tasks into a single prompt to save time and reduce API credit consumption. For example: Combine simple tasks into a single prompt to save time and reduce API credit consumption. For example:
*"Change the color scheme, add mobile responsiveness, and restart the dev server."* _"Change the color scheme, add mobile responsiveness, and restart the dev server."_
</details> </details>
<details> <details>
<summary><strong>How do I contribute to bolt.diy?</strong></summary> <summary><strong>How do I contribute to bolt.diy?</strong></summary>
Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved! Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved!
</details> </details>
<details> <details>
@@ -42,48 +44,60 @@ Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to g
Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates. Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates.
New features and improvements are on the way! New features and improvements are on the way!
</details> </details>
<details> <details>
<summary><strong>Why are there so many open issues/pull requests?</strong></summary> <summary><strong>Why are there so many open issues/pull requests?</strong></summary>
bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort! bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort!
We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive. We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive.
</details> </details>
<details> <details>
<summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary> <summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary>
While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs. While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs.
</details> </details>
<details> <details>
<summary><strong>Common Errors and Troubleshooting</strong></summary> <summary><strong>Common Errors and Troubleshooting</strong></summary>
### **"There was an error processing this request"** ### **"There was an error processing this request"**
This generic error message means something went wrong. Check both: This generic error message means something went wrong. Check both:
- The terminal (if you started the app with Docker or `pnpm`). - The terminal (if you started the app with Docker or `pnpm`).
- The developer console in your browser (press `F12` or right-click > *Inspect*, then go to the *Console* tab). - The developer console in your browser (press `F12` or right-click > _Inspect_, then go to the _Console_ tab).
### **"x-api-key header missing"** ### **"x-api-key header missing"**
This error is sometimes resolved by restarting the Docker container. This error is sometimes resolved by restarting the Docker container.
If that doesn't work, try switching from Docker to `pnpm` or vice versa. We're actively investigating this issue. If that doesn't work, try switching from Docker to `pnpm` or vice versa. We're actively investigating this issue.
### **Blank preview when running the app** ### **Blank preview when running the app**
A blank preview often occurs due to hallucinated bad code or incorrect commands. A blank preview often occurs due to hallucinated bad code or incorrect commands.
To troubleshoot: To troubleshoot:
- Check the developer console for errors. - Check the developer console for errors.
- Remember, previews are core functionality, so the app isn't broken! We're working on making these errors more transparent. - Remember, previews are core functionality, so the app isn't broken! We're working on making these errors more transparent.
### **"Everything works, but the results are bad"** ### **"Everything works, but the results are bad"**
Local LLMs like Qwen-2.5-Coder are powerful for small applications but still experimental for larger projects. For better results, consider using larger models like GPT-4o, Claude 3.5 Sonnet, or DeepSeek Coder V2 236b. Local LLMs like Qwen-2.5-Coder are powerful for small applications but still experimental for larger projects. For better results, consider using larger models like GPT-4o, Claude 3.5 Sonnet, or DeepSeek Coder V2 236b.
### **"Received structured exception #0xc0000005: access violation"** ### **"Received structured exception #0xc0000005: access violation"**
If you are getting this, you are probably on Windows. The fix is generally to update the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) If you are getting this, you are probably on Windows. The fix is generally to update the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)
### **"Miniflare or Wrangler errors in Windows"** ### **"Miniflare or Wrangler errors in Windows"**
You will need to make sure you have the latest version of Visual Studio C++ installed (14.40.33816), more information here https://github.com/stackblitz-labs/bolt.diy/issues/19. You will need to make sure you have the latest version of Visual Studio C++ installed (14.40.33816), more information here https://github.com/stackblitz-labs/bolt.diy/issues/19.
</details> </details>
--- ---

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 StackBlitz, Inc. Copyright (c) 2024 StackBlitz, Inc. and bolt.diy contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

57
PROJECT.md Normal file
View File

@@ -0,0 +1,57 @@
# Project management of bolt.diy
First off: this sounds funny, we know. "Project management" comes from a world of enterprise stuff and this project is
far from being enterprisy- it's still anarchy all over the place 😉
But we need to organize ourselves somehow, right?
> tl;dr: We've got a project board with epics and features. We use PRs as change log and as materialized features. Find it [here](https://github.com/orgs/stackblitz-labs/projects/4).
Here's how we structure long-term vision, mid-term capabilities of the software and short term improvements.
## Strategic epics (long-term)
Strategic epics define areas in which the product evolves. Usually, these epics dont overlap. They shall allow the core
team to define what they believe is most important and should be worked on with the highest priority.
You can find the [epics as issues](https://github.com/stackblitz-labs/bolt.diy/labels/epic) which are probably never
going to be closed.
What's the benefit / purpose of epics?
1. Prioritization
E. g. we could say “managing files is currently more important that quality”. Then, we could thing about which features
would bring “managing files” forward. It may be different features, such as “upload local files”, “import from a repo”
or also undo/redo/commit.
In a more-or-less regular meeting dedicated for that, the core team discusses which epics matter most, sketch features
and then check who can work on them. After the meeting, they update the roadmap (at least for the next development turn)
and this way communicate where the focus currently is.
2. Grouping of features
By linking features with epics, we can keep them together and document _why_ we invest work into a particular thing.
## Features (mid-term)
We all know probably a dozen of methodologies following which features are being described (User story, business
function, you name it).
However, we intentionally describe features in a more vague manner. Why? Everybody loves crisp, well-defined
acceptance-criteria, no? Well, every product owner loves it. because he knows what hell get once its done.
But: **here is no owner of this product**. Therefore, we grant _maximum flexibility to the developer contributing a feature_ so that he can bring in his ideas and have most fun implementing it.
The feature therefore tries to describe _what_ should be improved but not in detail _how_.
## PRs as materialized features (short-term)
Once a developer starts working on a feature, a draft-PR _can_ be opened asap to share, describe and discuss, how the feature shall be implemented. But: this is not a must. It just helps to get early feedback and get other developers involved. Sometimes, the developer just wants to get started and then open a PR later.
In a loosely organized project, it may as well happen that multiple PRs are opened for the same feature. This is no real issue: Usually, peoply being passionate about a solution are willing to join forces and get it done together. And if a second developer was just faster getting the same feature realized: Be happy that it's been done, close the PR and look out for the next feature to implement 🤓
## PRs as change log
Once a PR is merged, a squashed commit contains the whole PR description which allows for a good change log.
All authors of commits in the PR are mentioned in the squashed commit message and become contributors 🙌

View File

@@ -1,9 +1,16 @@
# bolt.diy (Previously oTToDev) # bolt.diy (Previously oTToDev)
[![bolt.diy: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.diy) [![bolt.diy: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.diy)
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models. Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more information. ---
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
---
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/). We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).
@@ -23,8 +30,15 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
## Join the community ## Join the community
[Join the bolt.diy community here, in the thinktank on ottomator.ai!](https://thinktank.ottomator.ai) [Join the bolt.diy community here, in the oTTomator Think Tank!](https://thinktank.ottomator.ai)
## Project management
Bolt.diy is a community effort! Still, the core team of contributors aims at organizing the project in way that allows
you to understand where the current areas of focus are.
If you want to know what we are working on, what we are planning to work on, or if you want to contribute to the
project, please check the [project management guide](./PROJECT.md) to get started easily.
## Requested Additions ## Requested Additions
@@ -47,6 +61,7 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus) - ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
- ✅ Streaming of code output (@thecodacus) - ✅ Streaming of code output (@thecodacus)
- ✅ Ability to revert code to earlier version (@wonderwhy-er) - ✅ Ability to revert code to earlier version (@wonderwhy-er)
- ✅ Chat history backup and restore functionality (@sidbetatester)
- ✅ Cohere Integration (@hasanraiyan) - ✅ Cohere Integration (@hasanraiyan)
- ✅ Dynamic model max token length (@hasanraiyan) - ✅ Dynamic model max token length (@hasanraiyan)
- ✅ Better prompt enhancing (@SujalXplores) - ✅ Better prompt enhancing (@SujalXplores)
@@ -55,7 +70,7 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- ✅ Together Integration (@mouimet-infinisoft) - ✅ Together Integration (@mouimet-infinisoft)
- ✅ Mobile friendly (@qwikode) - ✅ Mobile friendly (@qwikode)
- ✅ Better prompt enhancing (@SujalXplores) - ✅ Better prompt enhancing (@SujalXplores)
- ✅ Attach images to prompts (@atrokhym) - ✅ Attach images to prompts (@atrokhym)(@stijnus)
- ✅ Added Git Clone button (@thecodacus) - ✅ Added Git Clone button (@thecodacus)
- ✅ Git Import from url (@thecodacus) - ✅ Git Import from url (@thecodacus)
- ✅ PromptLibrary to have different variations of prompts for different use cases (@thecodacus) - ✅ PromptLibrary to have different variations of prompts for different use cases (@thecodacus)
@@ -64,6 +79,8 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- ✅ Detect terminal Errors and ask bolt to fix it (@thecodacus) - ✅ Detect terminal Errors and ask bolt to fix it (@thecodacus)
- ✅ Detect preview Errors and ask bolt to fix it (@wonderwhy-er) - ✅ Detect preview Errors and ask bolt to fix it (@wonderwhy-er)
- ✅ Add Starter Template Options (@thecodacus) - ✅ Add Starter Template Options (@thecodacus)
- ✅ Perplexity Integration (@meetpateltech)
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
-**HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs) -**HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
-**HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start) -**HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
-**HIGH PRIORITY** - Run agents in the backend as opposed to a single model call -**HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
@@ -73,12 +90,14 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc. - ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
- ⬜ Voice prompting - ⬜ Voice prompting
- ⬜ Azure Open AI API Integration - ⬜ Azure Open AI API Integration
- ✅ Perplexity Integration (@meetpateltech)
- ⬜ Vertex AI Integration - ⬜ Vertex AI Integration
- ⬜ Granite Integration
- ✅ Popout Window for Web Container(@stijnus)
- ✅ Ability to change Popout window size (@stijnus)
## Features ## Features
- **AI-powered full-stack web development** directly in your browser. - **AI-powered full-stack web development** for **NodeJS based applications** directly in your browser.
- **Support for multiple LLMs** with an extensible architecture to integrate additional models. - **Support for multiple LLMs** with an extensible architecture to integrate additional models.
- **Attach images to prompts** for better contextual understanding. - **Attach images to prompts** for better contextual understanding.
- **Integrated terminal** to view output of LLM-run commands. - **Integrated terminal** to view output of LLM-run commands.
@@ -86,21 +105,18 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- **Download projects as ZIP** for easy portability. - **Download projects as ZIP** for easy portability.
- **Integration-ready Docker support** for a hassle-free setup. - **Integration-ready Docker support** for a hassle-free setup.
## Setup ## Setup
If you're new to installing software from GitHub, don't worry! If you encounter any issues, feel free to submit an "issue" using the provided links or improve this documentation by forking the repository, editing the instructions, and submitting a pull request. The following instruction will help you get the stable branch up and running on your local machine in no time. If you're new to installing software from GitHub, don't worry! If you encounter any issues, feel free to submit an "issue" using the provided links or improve this documentation by forking the repository, editing the instructions, and submitting a pull request. The following instruction will help you get the stable branch up and running on your local machine in no time.
Let's get you up and running with the stable version of Bolt.DIY! Let's get you up and running with the stable version of Bolt.DIY!
## Quick Download ## Quick Download
[![Download Latest Release](https://img.shields.io/github/v/release/stackblitz-labs/bolt.diy?label=Download%20Bolt&sort=semver)](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go the the latest release version! [![Download Latest Release](https://img.shields.io/github/v/release/stackblitz-labs/bolt.diy?label=Download%20Bolt&sort=semver)](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go the the latest release version!
- Next **click source.zip** - Next **click source.zip**
## Prerequisites ## Prerequisites
Before you begin, you'll need to install two important pieces of software: Before you begin, you'll need to install two important pieces of software:
@@ -133,32 +149,35 @@ You have two options for running Bolt.DIY: directly on your machine or using Doc
### Option 1: Direct Installation (Recommended for Beginners) ### Option 1: Direct Installation (Recommended for Beginners)
1. **Install Package Manager (pnpm)**: 1. **Install Package Manager (pnpm)**:
```bash ```bash
npm install -g pnpm npm install -g pnpm
``` ```
2. **Install Project Dependencies**: 2. **Install Project Dependencies**:
```bash ```bash
pnpm install pnpm install
``` ```
3. **Start the Application**: 3. **Start the Application**:
```bash ```bash
pnpm run dev pnpm run dev
``` ```
**Important Note**: If you're using Google Chrome, you'll need Chrome Canary for local development. [Download it here](https://www.google.com/chrome/canary/)
### Option 2: Using Docker ### Option 2: Using Docker
This option requires some familiarity with Docker but provides a more isolated environment. This option requires some familiarity with Docker but provides a more isolated environment.
#### Additional Prerequisite #### Additional Prerequisite
- Install Docker: [Download Docker](https://www.docker.com/) - Install Docker: [Download Docker](https://www.docker.com/)
#### Steps: #### Steps:
1. **Build the Docker Image**: 1. **Build the Docker Image**:
```bash ```bash
# Using npm script: # Using npm script:
npm run dockerbuild npm run dockerbuild
@@ -169,12 +188,9 @@ This option requires some familiarity with Docker but provides a more isolated e
2. **Run the Container**: 2. **Run the Container**:
```bash ```bash
docker-compose --profile development up docker compose --profile development up
``` ```
## Configuring API Keys and Providers ## Configuring API Keys and Providers
### Adding Your API Keys ### Adding Your API Keys
@@ -203,6 +219,7 @@ For providers that support custom base URLs (such as Ollama or LM Studio), follo
> **Note**: Custom base URLs are particularly useful when running local instances of AI models or using custom API endpoints. > **Note**: Custom base URLs are particularly useful when running local instances of AI models or using custom API endpoints.
### Supported Providers ### Supported Providers
- Ollama - Ollama
- LM Studio - LM Studio
- OpenAILike - OpenAILike
@@ -210,23 +227,27 @@ For providers that support custom base URLs (such as Ollama or LM Studio), follo
## Setup Using Git (For Developers only) ## Setup Using Git (For Developers only)
This method is recommended for developers who want to: This method is recommended for developers who want to:
- Contribute to the project - Contribute to the project
- Stay updated with the latest changes - Stay updated with the latest changes
- Switch between different versions - Switch between different versions
- Create custom modifications - Create custom modifications
#### Prerequisites #### Prerequisites
1. Install Git: [Download Git](https://git-scm.com/downloads) 1. Install Git: [Download Git](https://git-scm.com/downloads)
#### Initial Setup #### Initial Setup
1. **Clone the Repository**: 1. **Clone the Repository**:
```bash ```bash
# Using HTTPS # Using HTTPS
git clone https://github.com/stackblitz-labs/bolt.diy.git git clone https://github.com/stackblitz-labs/bolt.diy.git
``` ```
2. **Navigate to Project Directory**: 2. **Navigate to Project Directory**:
```bash ```bash
cd bolt.diy cd bolt.diy
``` ```
@@ -236,6 +257,7 @@ This method is recommended for developers who want to:
git checkout main git checkout main
``` ```
4. **Install Dependencies**: 4. **Install Dependencies**:
```bash ```bash
pnpm install pnpm install
``` ```
@@ -250,16 +272,19 @@ This method is recommended for developers who want to:
To get the latest changes from the repository: To get the latest changes from the repository:
1. **Save Your Local Changes** (if any): 1. **Save Your Local Changes** (if any):
```bash ```bash
git stash git stash
``` ```
2. **Pull Latest Updates**: 2. **Pull Latest Updates**:
```bash ```bash
git pull origin main git pull origin main
``` ```
3. **Update Dependencies**: 3. **Update Dependencies**:
```bash ```bash
pnpm install pnpm install
``` ```
@@ -274,6 +299,7 @@ To get the latest changes from the repository:
If you encounter issues: If you encounter issues:
1. **Clean Installation**: 1. **Clean Installation**:
```bash ```bash
# Remove node modules and lock files # Remove node modules and lock files
rm -rf node_modules pnpm-lock.yaml rm -rf node_modules pnpm-lock.yaml

View File

@@ -0,0 +1,158 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, Profile } from './types';
const BetaLabel = () => (
<span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
BETA
</span>
);
interface AvatarDropdownProps {
onSelectTab: (tab: TabType) => void;
}
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
const profile = useStore(profileStore) as Profile;
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<motion.button
className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className="w-full h-full rounded-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
<div className="i-ph:question w-6 h-6" />
</div>
)}
</motion.button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<div
className={classNames(
'px-4 py-3 flex items-center gap-3',
'border-b border-gray-200/50 dark:border-gray-800/50',
)}
>
<div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
loading="eager"
decoding="sync"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
<span className="relative -top-0.5">?</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
{profile?.username || 'Guest User'}
</div>
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
</div>
</div>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('profile')}
>
<div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Edit Profile
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('settings')}
>
<div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Settings
</DropdownMenu.Item>
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('task-manager')}
>
<div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Task Manager
<BetaLabel />
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('service-status')}
>
<div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Service Status
<BetaLabel />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,555 @@
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { Switch } from '@radix-ui/react-switch';
import * as RadixDialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
import { TabTile } from '~/components/@settings/shared/components/TabTile';
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
import { useFeatures } from '~/lib/hooks/useFeatures';
import { useNotifications } from '~/lib/hooks/useNotifications';
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
import {
tabConfigurationStore,
developerModeStore,
setDeveloperMode,
resetTabConfiguration,
} from '~/lib/stores/settings';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, TabVisibilityConfig, Profile } from './types';
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
import { DialogTitle } from '~/components/ui/Dialog';
import { AvatarDropdown } from './AvatarDropdown';
import BackgroundRays from '~/components/ui/BackgroundRays';
// Import all tab components
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
import DataTab from '~/components/@settings/tabs/data/DataTab';
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
interface ControlPanelProps {
open: boolean;
onClose: () => void;
}
interface TabWithDevType extends TabVisibilityConfig {
isExtraDevTab?: boolean;
}
interface ExtendedTabConfig extends TabVisibilityConfig {
isExtraDevTab?: boolean;
}
interface BaseTabConfig {
id: TabType;
visible: boolean;
window: 'user' | 'developer';
order: number;
}
interface AnimatedSwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
id: string;
label: string;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
'tab-management': 'Configure visible tabs and their order',
};
// Beta status for experimental features
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
const BetaLabel = () => (
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
<span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
</div>
);
const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
return (
<div className="flex items-center gap-2">
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full',
'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
'bg-gray-200 dark:bg-gray-700',
'data-[state=checked]:bg-purple-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
'cursor-pointer',
'group',
)}
>
<motion.span
className={classNames(
'absolute left-[2px] top-[2px]',
'inline-block h-5 w-5 rounded-full',
'bg-white shadow-lg',
'transition-shadow duration-300',
'group-hover:shadow-md group-active:shadow-sm',
'group-hover:scale-95 group-active:scale-90',
)}
initial={false}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
duration: 0.2,
}}
animate={{
x: checked ? '1.25rem' : '0rem',
}}
>
<motion.div
className="absolute inset-0 rounded-full bg-white"
initial={false}
animate={{
scale: checked ? 1 : 0.8,
}}
transition={{ duration: 0.2 }}
/>
</motion.span>
<span className="sr-only">Toggle {label}</span>
</Switch>
<div className="flex items-center gap-2">
<label
htmlFor={id}
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
>
{label}
</label>
</div>
</div>
);
};
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
// State
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
// Store values
const tabConfiguration = useStore(tabConfigurationStore);
const developerMode = useStore(developerModeStore);
const profile = useStore(profileStore) as Profile;
// Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Memoize the base tab configurations to avoid recalculation
const baseTabConfig = useMemo(() => {
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
}, []);
// Add visibleTabs logic using useMemo with optimized calculations
const visibleTabs = useMemo(() => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, resetting to defaults');
resetTabConfiguration();
return [];
}
const notificationsDisabled = profile?.preferences?.notifications === false;
// In developer mode, show ALL tabs without restrictions
if (developerMode) {
const seenTabs = new Set<TabType>();
const devTabs: ExtendedTabConfig[] = [];
// Process tabs in order of priority: developer, user, default
const processTab = (tab: BaseTabConfig) => {
if (!seenTabs.has(tab.id)) {
seenTabs.add(tab.id);
devTabs.push({
id: tab.id,
visible: true,
window: 'developer',
order: tab.order || devTabs.length,
});
}
};
// Process tabs in priority order
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
// Add Tab Management tile
devTabs.push({
id: 'tab-management' as TabType,
visible: true,
window: 'developer',
order: devTabs.length,
isExtraDevTab: true,
});
return devTabs.sort((a, b) => a.order - b.order);
}
// Optimize user mode tab filtering
return tabConfiguration.userTabs
.filter((tab) => {
if (!tab?.id) {
return false;
}
if (tab.id === 'notifications' && notificationsDisabled) {
return false;
}
return tab.visible && tab.window === 'user';
})
.sort((a, b) => a.order - b.order);
}, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
// Optimize animation performance with layout animations
const gridLayoutVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: {
type: 'spring',
stiffness: 200,
damping: 20,
mass: 0.6,
},
},
};
// Reset to default view when modal opens/closes
useEffect(() => {
if (!open) {
// Reset when closing
setActiveTab(null);
setLoadingTab(null);
setShowTabManagement(false);
} else {
// When opening, set to null to show the main view
setActiveTab(null);
}
}, [open]);
// Handle closing
const handleClose = () => {
setActiveTab(null);
setLoadingTab(null);
setShowTabManagement(false);
onClose();
};
// Handlers
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
const handleDeveloperModeChange = (checked: boolean) => {
console.log('Developer mode changed:', checked);
setDeveloperMode(checked);
};
// Add effect to log developer mode changes
useEffect(() => {
console.log('Current developer mode:', developerMode);
}, [developerMode]);
const getTabComponent = (tabId: TabType | 'tab-management') => {
if (tabId === 'tab-management') {
return <TabManagement />;
}
switch (tabId) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
case 'task-manager':
return <TaskManagerTab />;
case 'service-status':
return <ServiceStatusTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'update':
return hasUpdate;
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'connection':
return hasConnectionIssues;
case 'debug':
return hasActiveWarnings;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'update':
return `New update available (v${currentVersion})`;
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'connection':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
case 'debug': {
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
const errors = activeIssues.filter((i) => i.type === 'error').length;
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
}
default:
return '';
}
};
const handleTabClick = (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
setShowTabManagement(false);
// Acknowledge notifications based on tab
switch (tabId) {
case 'update':
acknowledgeUpdate();
break;
case 'features':
acknowledgeAllFeatures();
break;
case 'notifications':
markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
acknowledgeAllIssues();
break;
}
// Clear loading state after a delay
setTimeout(() => setLoadingTab(null), 500);
};
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
className="relative z-[101]"
>
<motion.div
className={classNames(
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
'relative',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="absolute inset-0 overflow-hidden rounded-2xl">
<BackgroundRays />
</div>
<div className="relative z-10 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{(activeTab || showTabManagement) && (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center gap-6">
{/* Mode Toggle */}
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
<AnimatedSwitch
id="developer-mode"
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
label={developerMode ? 'Developer Mode' : 'User Mode'}
/>
</div>
{/* Avatar and Dropdown */}
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
<AvatarDropdown onSelectTab={handleTabClick} />
</div>
{/* Close Button */}
<button
onClick={handleClose}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6"
>
{showTabManagement ? (
<TabManagement />
) : activeTab ? (
getTabComponent(activeTab)
) : (
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
variants={gridLayoutVariants}
initial="hidden"
animate="visible"
>
<AnimatePresence mode="popLayout">
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
<TabTile
tab={tab}
onClick={() => handleTabClick(tab.id as TabType)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
className="h-full relative"
>
{BETA_TABS.has(tab.id) && <BetaLabel />}
</TabTile>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</motion.div>
</div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@@ -0,0 +1,88 @@
import type { TabType } from './types';
export const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:star-fill',
data: 'i-ph:database-fill',
'cloud-providers': 'i-ph:cloud-fill',
'local-providers': 'i-ph:desktop-fill',
'service-status': 'i-ph:activity-bold',
connection: 'i-ph:wifi-high-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
'task-manager': 'i-ph:chart-line-fill',
'tab-management': 'i-ph:squares-four-fill',
};
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
'service-status': 'Service Status',
connection: 'Connection',
debug: 'Debug',
'event-logs': 'Event Logs',
update: 'Updates',
'task-manager': 'Task Manager',
'tab-management': 'Tab Management',
};
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
'tab-management': 'Configure visible tabs and their order',
};
export const DEFAULT_TAB_CONFIG = [
// User Window Tabs (Always visible by default)
{ id: 'features', visible: true, window: 'user' as const, order: 0 },
{ id: 'data', visible: true, window: 'user' as const, order: 1 },
{ id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
{ id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
{ id: 'connection', visible: true, window: 'user' as const, order: 4 },
{ id: 'notifications', visible: true, window: 'user' as const, order: 5 },
{ id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
// User Window Tabs (In dropdown, initially hidden)
{ id: 'profile', visible: false, window: 'user' as const, order: 7 },
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
{ id: 'service-status', visible: false, window: 'user' as const, order: 10 },
// User Window Tabs (Hidden, controlled by TaskManagerTab)
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
{ id: 'update', visible: false, window: 'user' as const, order: 12 },
// Developer Window Tabs (All visible by default)
{ id: 'features', visible: true, window: 'developer' as const, order: 0 },
{ id: 'data', visible: true, window: 'developer' as const, order: 1 },
{ id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
{ id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
{ id: 'connection', visible: true, window: 'developer' as const, order: 4 },
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
{ id: 'profile', visible: true, window: 'developer' as const, order: 7 },
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
{ id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
{ id: 'update', visible: true, window: 'developer' as const, order: 12 },
];

View File

@@ -0,0 +1,114 @@
import type { ReactNode } from 'react';
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
export type TabType =
| 'profile'
| 'settings'
| 'notifications'
| 'features'
| 'data'
| 'cloud-providers'
| 'local-providers'
| 'service-status'
| 'connection'
| 'debug'
| 'event-logs'
| 'update'
| 'task-manager'
| 'tab-management';
export type WindowType = 'user' | 'developer';
export interface UserProfile {
nickname: any;
name: string;
email: string;
avatar?: string;
theme: 'light' | 'dark' | 'system';
notifications: boolean;
password?: string;
bio?: string;
language: string;
timezone: string;
}
export interface SettingItem {
id: TabType;
label: string;
icon: string;
category: SettingCategory;
description?: string;
component: () => ReactNode;
badge?: string;
keywords?: string[];
}
export interface TabVisibilityConfig {
id: TabType;
visible: boolean;
window: WindowType;
order: number;
isExtraDevTab?: boolean;
locked?: boolean;
}
export interface DevTabConfig extends TabVisibilityConfig {
window: 'developer';
}
export interface UserTabConfig extends TabVisibilityConfig {
window: 'user';
}
export interface TabWindowConfig {
userTabs: UserTabConfig[];
developerTabs: DevTabConfig[];
}
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
'service-status': 'Service Status',
connection: 'Connections',
debug: 'Debug',
'event-logs': 'Event Logs',
update: 'Updates',
'task-manager': 'Task Manager',
'tab-management': 'Tab Management',
};
export const categoryLabels: Record<SettingCategory, string> = {
profile: 'Profile & Account',
file_sharing: 'File Sharing',
connectivity: 'Connectivity',
system: 'System',
services: 'Services',
preferences: 'Preferences',
};
export const categoryIcons: Record<SettingCategory, string> = {
profile: 'i-ph:user-circle',
file_sharing: 'i-ph:folder-simple',
connectivity: 'i-ph:wifi-high',
system: 'i-ph:gear',
services: 'i-ph:cube',
preferences: 'i-ph:sliders',
};
export interface Profile {
username?: string;
bio?: string;
avatar?: string;
preferences?: {
notifications?: boolean;
theme?: 'light' | 'dark' | 'system';
language?: string;
timezone?: string;
};
}

View File

@@ -0,0 +1,14 @@
// Core exports
export { ControlPanel } from './core/ControlPanel';
export type { TabType, TabVisibilityConfig } from './core/types';
// Constants
export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants';
// Shared components
export { TabTile } from './shared/components/TabTile';
export { TabManagement } from './shared/components/TabManagement';
// Utils
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
export * from './utils/animations';

View File

@@ -0,0 +1,163 @@
import { useDrag, useDrop } from 'react-dnd';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
import { TAB_LABELS } from '~/components/@settings/core/types';
import { Switch } from '~/components/ui/Switch';
interface DraggableTabListProps {
tabs: TabVisibilityConfig[];
onReorder: (tabs: TabVisibilityConfig[]) => void;
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
showControls?: boolean;
}
interface DraggableTabItemProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
showControls?: boolean;
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
}
interface DragItem {
type: string;
index: number;
id: string;
}
const DraggableTabItem = ({
tab,
index,
moveTab,
showControls,
onWindowChange,
onVisibilityChange,
}: DraggableTabItemProps) => {
const [{ isDragging }, dragRef] = useDrag({
type: 'tab',
item: { type: 'tab', index, id: tab.id },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, dropRef] = useDrop({
accept: 'tab',
hover: (item: DragItem, monitor) => {
if (!monitor.isOver({ shallow: true })) {
return;
}
if (item.index === index) {
return;
}
if (item.id === tab.id) {
return;
}
moveTab(item.index, index);
item.index = index;
},
});
const ref = (node: HTMLDivElement | null) => {
dragRef(node);
dropRef(node);
};
return (
<motion.div
ref={ref}
initial={false}
animate={{
scale: isDragging ? 1.02 : 1,
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
}}
className={classNames(
'flex items-center justify-between p-4 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
isDragging ? 'z-50' : '',
)}
>
<div className="flex items-center gap-4">
<div className="cursor-grab">
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
</div>
<div>
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
{showControls && (
<div className="text-xs text-bolt-elements-textSecondary">
Order: {tab.order}, Window: {tab.window}
</div>
)}
</div>
</div>
{showControls && !tab.locked && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tab.visible}
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
className="data-[state=checked]:bg-purple-500"
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
/>
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-bolt-elements-textSecondary">User</label>
<Switch
checked={tab.window === 'developer'}
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
className="data-[state=checked]:bg-purple-500"
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
/>
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
</div>
</div>
)}
</motion.div>
);
};
export const DraggableTabList = ({
tabs,
onReorder,
onWindowChange,
onVisibilityChange,
showControls = false,
}: DraggableTabListProps) => {
const moveTab = (dragIndex: number, hoverIndex: number) => {
const items = Array.from(tabs);
const [reorderedItem] = items.splice(dragIndex, 1);
items.splice(hoverIndex, 0, reorderedItem);
// Update order numbers based on position
const reorderedTabs = items.map((tab, index) => ({
...tab,
order: index + 1,
}));
onReorder(reorderedTabs);
};
return (
<div className="space-y-2">
{tabs.map((tab, index) => (
<DraggableTabItem
key={tab.id}
tab={tab}
index={index}
moveTab={moveTab}
showControls={showControls}
onWindowChange={onWindowChange}
onVisibilityChange={onVisibilityChange}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,380 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { Switch } from '~/components/ui/Switch';
import { classNames } from '~/utils/classNames';
import { tabConfigurationStore } from '~/lib/stores/settings';
import { TAB_LABELS } from '~/components/@settings/core/constants';
import type { TabType } from '~/components/@settings/core/types';
import { toast } from 'react-toastify';
import { TbLayoutGrid } from 'react-icons/tb';
import { useSettingsStore } from '~/lib/stores/settings';
// Define tab icons mapping
const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:star-fill',
data: 'i-ph:database-fill',
'cloud-providers': 'i-ph:cloud-fill',
'local-providers': 'i-ph:desktop-fill',
'service-status': 'i-ph:activity-fill',
connection: 'i-ph:wifi-high-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
'task-manager': 'i-ph:chart-line-fill',
'tab-management': 'i-ph:squares-four-fill',
};
// Define which tabs are default in user mode
const DEFAULT_USER_TABS: TabType[] = [
'features',
'data',
'cloud-providers',
'local-providers',
'connection',
'notifications',
'event-logs',
];
// Define which tabs can be added to user mode
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
// All available tabs for user mode
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
// Define which tabs are beta
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
// Beta label component
const BetaLabel = () => (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-purple-500/10 text-purple-500 font-medium">BETA</span>
);
export const TabManagement = () => {
const [searchQuery, setSearchQuery] = useState('');
const tabConfiguration = useStore(tabConfigurationStore);
const { setSelectedTab } = useSettingsStore();
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
// Get current tab configuration
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
// If tab doesn't exist in configuration, create it
if (!currentTab) {
const newTab = {
id: tabId,
visible: checked,
window: 'user' as const,
order: tabConfiguration.userTabs.length,
};
const updatedTabs = [...tabConfiguration.userTabs, newTab];
tabConfigurationStore.set({
...tabConfiguration,
userTabs: updatedTabs,
});
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
return;
}
// Check if tab can be enabled in user mode
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
if (!canBeEnabled && checked) {
toast.error('This tab cannot be enabled in user mode');
return;
}
// Update tab visibility
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, visible: checked };
}
return tab;
});
// Update store
tabConfigurationStore.set({
...tabConfiguration,
userTabs: updatedTabs,
});
// Show success message
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
};
// Create a map of existing tab configurations
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
// Generate the complete list of tabs, including those not in the configuration
const allTabs = ALL_USER_TABS.map((tabId) => {
return (
tabConfigMap.get(tabId) || {
id: tabId,
visible: false,
window: 'user' as const,
order: -1,
}
);
});
// Filter tabs based on search query
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
useEffect(() => {
// Reset to first tab when component unmounts
return () => {
setSelectedTab('user'); // Reset to user tab when unmounting
};
}, [setSelectedTab]);
return (
<div className="space-y-6">
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header */}
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
'text-purple-500',
)}
>
<TbLayoutGrid className="w-5 h-5" />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
</div>
</div>
{/* Search */}
<div className="relative w-64">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tabs..."
className={classNames(
'w-full pl-10 pr-4 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
/>
</div>
</div>
{/* Tab Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Default Section Header */}
{filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && (
<div className="col-span-full flex items-center gap-2 mt-4 mb-2">
<div className="i-ph:star-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Default Tabs</span>
</div>
)}
{/* Default Tabs */}
{filteredTabs
.filter((tab) => DEFAULT_USER_TABS.includes(tab.id))
.map((tab, index) => (
<motion.div
key={tab.id}
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
{/* Status Badges */}
<div className="absolute top-1 right-1.5 flex gap-1">
<span className="px-1.5 py-0.25 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium mr-2">
Default
</span>
</div>
<div className="flex items-start gap-4 p-4">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
>
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
</div>
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{TAB_LABELS[tab.id]}
</h4>
{BETA_TABS.has(tab.id) && <BetaLabel />}
</div>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
</p>
</div>
<Switch
checked={tab.visible}
onCheckedChange={(checked) => {
const isDisabled =
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
if (!isDisabled) {
handleTabVisibilityChange(tab.id, checked);
}
}}
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
'opacity-50 pointer-events-none':
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
})}
/>
</div>
</div>
</div>
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: tab.visible ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
{/* Optional Section Header */}
{filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && (
<div className="col-span-full flex items-center gap-2 mt-8 mb-2">
<div className="i-ph:plus-circle-fill w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Optional Tabs</span>
</div>
)}
{/* Optional Tabs */}
{filteredTabs
.filter((tab) => OPTIONAL_USER_TABS.includes(tab.id))
.map((tab, index) => (
<motion.div
key={tab.id}
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
{/* Status Badges */}
<div className="absolute top-1 right-1.5 flex gap-1">
<span className="px-1.5 py-0.25 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium mr-2">
Optional
</span>
</div>
<div className="flex items-start gap-4 p-4">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
>
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
</div>
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{TAB_LABELS[tab.id]}
</h4>
{BETA_TABS.has(tab.id) && <BetaLabel />}
</div>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
</p>
</div>
<Switch
checked={tab.visible}
onCheckedChange={(checked) => {
const isDisabled =
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
if (!isDisabled) {
handleTabVisibilityChange(tab.id, checked);
}
}}
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
'opacity-50 pointer-events-none':
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
})}
/>
</div>
</div>
</div>
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: tab.visible ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
</div>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1,135 @@
import { motion } from 'framer-motion';
import * as Tooltip from '@radix-ui/react-tooltip';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
interface TabTileProps {
tab: TabVisibilityConfig;
onClick?: () => void;
isActive?: boolean;
hasUpdate?: boolean;
statusMessage?: string;
description?: string;
isLoading?: boolean;
className?: string;
children?: React.ReactNode;
}
export const TabTile: React.FC<TabTileProps> = ({
tab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
className,
children,
}: TabTileProps) => {
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<motion.div
onClick={onClick}
className={classNames(
'relative flex flex-col items-center p-6 rounded-xl',
'w-full h-full min-h-[160px]',
'bg-white dark:bg-[#141414]',
'border border-[#E5E5E5] dark:border-[#333333]',
'group',
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
'hover:border-purple-200 dark:hover:border-purple-900/30',
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
isLoading ? 'cursor-wait opacity-70' : '',
className || '',
)}
>
{/* Main Content */}
<div className="flex flex-col items-center justify-center flex-1 w-full">
{/* Icon */}
<motion.div
className={classNames(
'relative',
'w-14 h-14',
'flex items-center justify-center',
'rounded-xl',
'bg-gray-100 dark:bg-gray-800',
'ring-1 ring-gray-200 dark:ring-gray-700',
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
)}
>
<motion.div
className={classNames(
TAB_ICONS[tab.id],
'w-8 h-8',
'text-gray-600 dark:text-gray-300',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)}
/>
</motion.div>
{/* Label and Description */}
<div className="flex flex-col items-center mt-5 w-full">
<h3
className={classNames(
'text-[15px] font-medium leading-snug mb-2',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)}
>
{TAB_LABELS[tab.id]}
</h3>
{description && (
<p
className={classNames(
'text-[13px] leading-relaxed',
'text-gray-500 dark:text-gray-400',
'max-w-[85%]',
'text-center',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
)}
>
{description}
</p>
)}
</div>
</div>
{/* Update Indicator with Tooltip */}
{hasUpdate && (
<>
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</>
)}
{/* Children (e.g. Beta Label) */}
{children}
</motion.div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@@ -0,0 +1,615 @@
import React, { useState, useEffect } from 'react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
created_at: string;
public_gists: number;
}
interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
languages_url: string;
}
interface GitHubOrganization {
login: string;
avatar_url: string;
html_url: string;
}
interface GitHubEvent {
id: string;
type: string;
repo: {
name: string;
};
created_at: string;
}
interface GitHubLanguageStats {
[language: string]: number;
}
interface GitHubStats {
repos: GitHubRepoInfo[];
totalStars: number;
totalForks: number;
organizations: GitHubOrganization[];
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
}
interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
}
export default function ConnectionsTab() {
const [connection, setConnection] = useState<GitHubConnection>({
user: null,
token: '',
tokenType: 'classic',
});
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
// Load saved connection on mount
useEffect(() => {
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
// Ensure backward compatibility with existing connections
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
setConnection(parsed);
if (parsed.user && parsed.token) {
fetchGitHubStats(parsed.token);
}
}
setIsLoading(false);
}, []);
const fetchGitHubStats = async (token: string) => {
try {
setIsFetchingStats(true);
// Fetch repositories - only owned by the authenticated user
const reposResponse = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!reposResponse.ok) {
throw new Error('Failed to fetch repositories');
}
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
// Fetch organizations
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!orgsResponse.ok) {
throw new Error('Failed to fetch organizations');
}
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
// Fetch recent activity
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!eventsResponse.ok) {
throw new Error('Failed to fetch events');
}
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
// Fetch languages for each repository
const languagePromises = repos.map((repo) =>
fetch(repo.languages_url, {
headers: {
Authorization: `Bearer ${token}`,
},
}).then((res) => res.json() as Promise<Record<string, number>>),
);
const repoLanguages = await Promise.all(languagePromises);
const languages: GitHubLanguageStats = {};
repoLanguages.forEach((repoLang) => {
Object.entries(repoLang).forEach(([lang, bytes]) => {
languages[lang] = (languages[lang] || 0) + bytes;
});
});
// Calculate total stats
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
const totalGists = connection.user?.public_gists || 0;
setConnection((prev) => ({
...prev,
stats: {
repos,
totalStars,
totalForks,
organizations,
recentActivity,
languages,
totalGists,
},
}));
} catch (error) {
logStore.logError('Failed to fetch GitHub stats', { error });
toast.error('Failed to fetch GitHub statistics');
} finally {
setIsFetchingStats(false);
}
};
const fetchGithubUser = async (token: string) => {
try {
setIsConnecting(true);
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const data = (await response.json()) as GitHubUserResponse;
const newConnection: GitHubConnection = {
user: data,
token,
tokenType: connection.tokenType,
};
// Save connection
localStorage.setItem('github_connection', JSON.stringify(newConnection));
setConnection(newConnection);
// Fetch additional stats
await fetchGitHubStats(token);
toast.success('Successfully connected to GitHub');
} catch (error) {
logStore.logError('Failed to authenticate with GitHub', { error });
toast.error('Failed to connect to GitHub');
setConnection({ user: null, token: '', tokenType: 'classic' });
} finally {
setIsConnecting(false);
}
};
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
await fetchGithubUser(connection.token);
};
const handleDisconnect = () => {
localStorage.removeItem('github_connection');
setConnection({ user: null, token: '', tokenType: 'classic' });
toast.success('Disconnected from GitHub');
};
if (isLoading) {
return <LoadingSpinner />;
}
return (
<div className="space-y-4">
{/* Header */}
<motion.div
className="flex items-center gap-2 mb-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary mb-6">
Manage your external service connections and integrations
</p>
<div className="grid grid-cols-1 gap-4">
{/* GitHub Connection */}
<motion.div
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center gap-2">
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
<select
value={connection.tokenType}
onChange={(e) =>
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
}
disabled={isConnecting || !!connection.user}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
'disabled:opacity-50',
)}
>
<option value="classic">Personal Access Token (Classic)</option>
<option value="fine-grained">Fine-grained Token</option>
</select>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input
type="password"
value={connection.token}
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
disabled={isConnecting || !!connection.user}
placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-10 h-5" />
</a>
<span className="mx-2"></span>
<span>
Required scopes:{' '}
{connection.tokenType === 'classic'
? 'repo, read:org, read:user'
: 'Repository access, Organization access'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{!connection.user ? (
<button
onClick={handleConnect}
disabled={isConnecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
) : (
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug-x w-4 h-4" />
Disconnect
</button>
)}
{connection.user && (
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4" />
Connected to GitHub
</span>
)}
</div>
{connection.user && (
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<div className="flex items-center gap-4">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="w-12 h-12 rounded-full"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
</div>
</div>
{isFetchingStats ? (
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching GitHub stats...
</div>
) : (
connection.stats && (
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
<p className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.user.public_repos}
</p>
</div>
<div>
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
<p className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.totalStars}
</p>
</div>
<div>
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
<p className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.totalForks}
</p>
</div>
</div>
)
)}
</div>
)}
{connection.user && connection.stats && (
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
<div className="flex items-center gap-4 mb-6">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="w-16 h-16 rounded-full"
/>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.user.name || connection.user.login}
</h3>
{connection.user.bio && (
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
)}
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:users w-4 h-4" />
{connection.user.followers} followers
</span>
<span className="flex items-center gap-1">
<div className="i-ph:star w-4 h-4" />
{connection.stats.totalStars} stars
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-4 h-4" />
{connection.stats.totalForks} forks
</span>
</div>
</div>
</div>
{/* Organizations Section */}
{connection.stats.organizations.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
<div className="flex flex-wrap gap-3">
{connection.stats.organizations.map((org) => (
<a
key={org.login}
href={org.html_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
>
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
</a>
))}
</div>
</div>
)}
{/* Languages Section */}
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(connection.stats.languages)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([language]) => (
<span
key={language}
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
>
{language}
</span>
))}
</div>
</div>
{/* Recent Activity Section */}
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
<div className="space-y-3">
{connection.stats.recentActivity.map((event) => (
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
<span className="font-medium">{event.type.replace('Event', '')}</span>
<span>on</span>
<a
href={`https://github.com/${event.repo.name}`}
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline"
>
{event.repo.name}
</a>
</div>
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
{new Date(event.created_at).toLocaleDateString()} at{' '}
{new Date(event.created_at).toLocaleTimeString()}
</div>
</div>
))}
</div>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
<div className="text-lg font-medium text-bolt-elements-textPrimary">
{new Date(connection.user.created_at).toLocaleDateString()}
</div>
</div>
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
<div className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.totalGists}
</div>
</div>
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
<div className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.organizations.length}
</div>
</div>
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
<div className="text-lg font-medium text-bolt-elements-textPrimary">
{Object.keys(connection.stats.languages).length}
</div>
</div>
</div>
{/* Existing repositories section */}
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
<div className="space-y-3">
{connection.stats.repos.map((repo) => (
<a
key={repo.full_name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
{repo.name}
</h5>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
)}
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:git-branch w-3 h-3" />
{repo.default_branch}
</span>
<span></span>
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:star w-3 h-3" />
{repo.stargazers_count}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-3 h-3" />
{repo.forks_count}
</span>
</div>
</div>
</a>
))}
</div>
</div>
)}
</div>
</motion.div>
</div>
</div>
);
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center p-4">
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-bolt-elements-textSecondary">Loading...</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
import Cookies from 'js-cookie';
import { getLocalStorage } from '~/lib/persistence';
const GITHUB_TOKEN_KEY = 'github_token';
interface ConnectionFormProps {
authState: GitHubAuthState;
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
onSave: (e: React.FormEvent) => void;
onDisconnect: () => void;
}
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount
useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: savedToken,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
}));
}
}, []);
return (
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
</div>
</div>
</div>
<form onSubmit={onSave} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
GitHub Username
</label>
<input
id="username"
type="text"
value={authState.username}
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="e.g., octocat"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
Personal Access Token
</label>
<a
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
target="_blank"
rel="noopener noreferrer"
className={classNames(
'inline-flex items-center gap-1.5 text-xs',
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
'transition-colors duration-200',
)}
>
<span>Generate new token</span>
<div className="i-ph:plus-circle" />
</a>
</div>
<input
id="token"
type="password"
value={authState.tokenInfo?.token || ''}
onChange={(e) =>
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: e.target.value,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
username: '',
isConnected: false,
isVerifying: false,
isLoadingRepos: false,
}))
}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="ghp_xxxxxxxxxxxx"
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-4">
{!authState.isConnected ? (
<button
type="submit"
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-purple-500 hover:bg-purple-600',
'text-white',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{authState.isVerifying ? (
<>
<div className="i-ph:spinner animate-spin" />
<span>Verifying...</span>
</>
) : (
<>
<div className="i-ph:plug-fill" />
<span>Connect</span>
</>
)}
</button>
) : (
<>
<button
onClick={onDisconnect}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
'text-bolt-elements-textPrimary',
)}
>
<div className="i-ph:plug-fill" />
<span>Disconnect</span>
</button>
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
<div className="i-ph:check-circle-fill" />
<span>Connected</span>
</span>
</>
)}
</div>
{authState.rateLimits && (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
<div className="i-ph:clock-countdown opacity-60" />
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
</div>
)}
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
import { GitBranch } from '@phosphor-icons/react';
interface GitHubBranch {
name: string;
default?: boolean;
}
interface CreateBranchDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (branchName: string, sourceBranch: string) => void;
repository: GitHubRepoInfo;
branches?: GitHubBranch[];
}
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm(branchName, sourceBranch);
setBranchName('');
onClose();
};
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
<Dialog.Content
className={classNames(
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-full max-w-md p-6 rounded-xl shadow-lg',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
Create New Branch
</Dialog.Title>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
Branch Name
</label>
<input
id="branchName"
type="text"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
placeholder="feature/my-new-branch"
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
required
/>
</div>
<div>
<label
htmlFor="sourceBranch"
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
>
Source Branch
</label>
<select
id="sourceBranch"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
>
{branches?.map((branch) => (
<option key={branch.name} value={branch.name}>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
</div>
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
<li className="flex items-center gap-2">
<GitBranch className="text-lg" />
Repository: {repository.name}
</li>
{branchName && (
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
New branch will be created as: {branchName}
</li>
)}
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
Based on: {sourceBranch}
</li>
</ul>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-bolt-elements-textPrimary',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'transition-colors',
)}
>
Cancel
</button>
<button
type="submit"
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-white bg-purple-500',
'hover:bg-purple-600',
'transition-colors',
)}
>
Create Branch
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,528 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { motion } from 'framer-motion';
import { getLocalStorage } from '~/lib/persistence';
import { classNames } from '~/utils/classNames';
import type { GitHubUserResponse } from '~/types/GitHub';
import { logStore } from '~/lib/stores/logs';
import { workbenchStore } from '~/lib/stores/workbench';
import { extractRelativePath } from '~/utils/diff';
import { formatSize } from '~/utils/formatSize';
import type { FileMap, File } from '~/lib/stores/files';
import { Octokit } from '@octokit/rest';
interface PushToGitHubDialogProps {
isOpen: boolean;
onClose: () => void;
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
private: boolean;
}
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
const [repoName, setRepoName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<GitHubUserResponse | null>(null);
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
// Load GitHub connection on mount
useEffect(() => {
if (isOpen) {
const connection = getLocalStorage('github_connection');
if (connection?.user && connection?.token) {
setUser(connection.user);
// Only fetch if we have both user and token
if (connection.token.trim()) {
fetchRecentRepos(connection.token);
}
}
}
}, [isOpen]);
const fetchRecentRepos = async (token: string) => {
if (!token) {
logStore.logError('No GitHub token available');
toast.error('GitHub authentication required');
return;
}
try {
setIsFetchingRepos(true);
const response = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token.trim()}`,
},
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
toast.error('GitHub token expired. Please reconnect your account.');
// Clear invalid token
const connection = getLocalStorage('github_connection');
if (connection) {
localStorage.removeItem('github_connection');
setUser(null);
}
} else {
logStore.logError('Failed to fetch GitHub repositories', {
status: response.status,
statusText: response.statusText,
error: errorData,
});
toast.error(`Failed to fetch repositories: ${response.statusText}`);
}
return;
}
const repos = (await response.json()) as GitHubRepo[];
setRecentRepos(repos);
} catch (error) {
logStore.logError('Failed to fetch GitHub repositories', { error });
toast.error('Failed to fetch recent repositories');
} finally {
setIsFetchingRepos(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const connection = getLocalStorage('github_connection');
if (!connection?.token || !connection?.user) {
toast.error('Please connect your GitHub account in Settings > Connections first');
return;
}
if (!repoName.trim()) {
toast.error('Repository name is required');
return;
}
setIsLoading(true);
try {
// Check if repository exists first
const octokit = new Octokit({ auth: connection.token });
try {
await octokit.repos.get({
owner: connection.user.login,
repo: repoName,
});
// If we get here, the repo exists
const confirmOverwrite = window.confirm(
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
);
if (!confirmOverwrite) {
setIsLoading(false);
return;
}
} catch (error) {
// 404 means repo doesn't exist, which is what we want for new repos
if (error instanceof Error && 'status' in error && error.status !== 404) {
throw error;
}
}
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
setCreatedRepoUrl(repoUrl);
// Get list of pushed files
const files = workbenchStore.files.get();
const filesList = Object.entries(files as FileMap)
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
.map(([path, dirent]) => ({
path: extractRelativePath(path),
size: new TextEncoder().encode((dirent as File).content || '').length,
}));
setPushedFiles(filesList);
setShowSuccessDialog(true);
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setRepoName('');
setIsPrivate(false);
setShowSuccessDialog(false);
setCreatedRepoUrl('');
onClose();
};
// Success Dialog
if (showSuccessDialog) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
>
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-green-500">
<div className="i-ph:check-circle w-5 h-5" />
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
</div>
<Dialog.Close
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
>
<div className="i-ph:x w-5 h-5" />
</Dialog.Close>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Repository URL
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
{createdRepoUrl}
</code>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph:copy w-4 h-4" />
</motion.button>
</div>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Pushed Files ({pushedFiles.length})
</p>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
{pushedFiles.map((file) => (
<div
key={file.path}
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
<span className="font-mono truncate flex-1">{file.path}</span>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
{formatSize(file.size)}
</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<motion.a
href={createdRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:github-logo w-4 h-4" />
View Repository
</motion.a>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:copy w-4 h-4" />
Copy URL
</motion.button>
<motion.button
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Close
</motion.button>
</div>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
if (!user) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:github-logo w-6 h-6" />
</motion.div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
</p>
<motion.button
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleClose}
>
<div className="i-ph:x-circle" />
Close
</motion.button>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
<div className="p-6">
<div className="flex items-center gap-4 mb-6">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:git-branch w-5 h-5" />
</motion.div>
<div>
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
Push to GitHub
</Dialog.Title>
<p className="text-sm text-gray-600 dark:text-gray-400">
Push your code to a new or existing GitHub repository
</p>
</div>
<Dialog.Close
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
onClick={handleClose}
>
<div className="i-ph:x w-5 h-5" />
</Dialog.Close>
</div>
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
Repository Name
</label>
<input
id="repoName"
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value)}
placeholder="my-awesome-project"
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
required
/>
</div>
{recentRepos.length > 0 && (
<div className="space-y-2">
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
<div className="space-y-2">
{recentRepos.map((repo) => (
<motion.button
key={repo.full_name}
type="button"
onClick={() => setRepoName(repo.name)}
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
{repo.name}
</span>
</div>
{repo.private && (
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
Private
</span>
)}
</div>
{repo.description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{repo.description}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{repo.language && (
<span className="flex items-center gap-1">
<div className="i-ph:code w-3 h-3" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1">
<div className="i-ph:star w-3 h-3" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-3 h-3" />
{repo.forks_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</motion.button>
))}
</div>
</div>
)}
{isFetchingRepos && (
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Loading repositories...
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="private"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
/>
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
Make repository private
</label>
</div>
<div className="pt-4 flex gap-2">
<motion.button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Cancel
</motion.button>
<motion.button
type="submit"
disabled={isLoading}
className={classNames(
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
isLoading ? 'opacity-50 cursor-not-allowed' : '',
)}
whileHover={!isLoading ? { scale: 1.02 } : {}}
whileTap={!isLoading ? { scale: 0.98 } : {}}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
Pushing...
</>
) : (
<>
<div className="i-ph:git-branch w-4 h-4" />
Push to GitHub
</>
)}
</motion.button>
</div>
</form>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,693 @@
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { getLocalStorage } from '~/lib/persistence';
import { motion } from 'framer-motion';
import { formatSize } from '~/utils/formatSize';
import { Input } from '~/components/ui/Input';
interface GitHubTreeResponse {
tree: Array<{
path: string;
type: string;
size?: number;
}>;
}
interface RepositorySelectionDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (url: string) => void;
}
interface SearchFilters {
language?: string;
stars?: number;
forks?: number;
}
interface StatsDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
stats: RepositoryStats;
isLargeRepo?: boolean;
}
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
<div className="p-6 space-y-4">
<div>
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
<div className="mt-4 space-y-2">
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
<div className="flex items-center gap-2">
<span className="i-ph:files text-purple-500 w-4 h-4" />
<span>Total Files: {stats.totalFiles}</span>
</div>
<div className="flex items-center gap-2">
<span className="i-ph:database text-purple-500 w-4 h-4" />
<span>Total Size: {formatSize(stats.totalSize)}</span>
</div>
<div className="flex items-center gap-2">
<span className="i-ph:code text-purple-500 w-4 h-4" />
<span>
Languages:{' '}
{Object.entries(stats.languages)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
.join(', ')}
</span>
</div>
{stats.hasPackageJson && (
<div className="flex items-center gap-2">
<span className="i-ph:package text-purple-500 w-4 h-4" />
<span>Has package.json</span>
</div>
)}
{stats.hasDependencies && (
<div className="flex items-center gap-2">
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
<span>Has dependencies</span>
</div>
)}
</div>
</div>
{isLargeRepo && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
<div className="text-yellow-800 dark:text-yellow-500">
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
and could impact performance.
</div>
</div>
)}
</div>
</div>
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
>
OK
</button>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
const [customUrl, setCustomUrl] = useState('');
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
const [selectedBranch, setSelectedBranch] = useState('');
const [filters, setFilters] = useState<SearchFilters>({});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [stats, setStats] = useState<RepositoryStats | null>(null);
const [showStatsDialog, setShowStatsDialog] = useState(false);
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
// Fetch user's repositories when dialog opens
useEffect(() => {
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
}, [isOpen, activeTab]);
const fetchUserRepos = async () => {
const connection = getLocalStorage('github_connection');
if (!connection?.token) {
toast.error('Please connect your GitHub account first');
return;
}
setIsLoading(true);
try {
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch repositories');
}
const data = await response.json();
// Add type assertion and validation
if (
Array.isArray(data) &&
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
) {
setRepositories(data as GitHubRepoInfo[]);
} else {
throw new Error('Invalid repository data format');
}
} catch (error) {
console.error('Error fetching repos:', error);
toast.error('Failed to fetch your repositories');
} finally {
setIsLoading(false);
}
};
const handleSearch = async (query: string) => {
setIsLoading(true);
setSearchResults([]);
try {
let searchQuery = query;
if (filters.language) {
searchQuery += ` language:${filters.language}`;
}
if (filters.stars) {
searchQuery += ` stars:>${filters.stars}`;
}
if (filters.forks) {
searchQuery += ` forks:>${filters.forks}`;
}
const response = await fetch(
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
throw new Error('Failed to search repositories');
}
const data = await response.json();
// Add type assertion and validation
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
setSearchResults(data.items as GitHubRepoInfo[]);
} else {
throw new Error('Invalid search results format');
}
} catch (error) {
console.error('Error searching repos:', error);
toast.error('Failed to search repositories');
} finally {
setIsLoading(false);
}
};
const fetchBranches = async (repo: GitHubRepoInfo) => {
setIsLoading(true);
try {
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
headers: {
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch branches');
}
const data = await response.json();
// Add type assertion and validation
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
setBranches(
data.map((branch) => ({
name: branch.name,
default: branch.name === repo.default_branch,
})),
);
} else {
throw new Error('Invalid branch data format');
}
} catch (error) {
console.error('Error fetching branches:', error);
toast.error('Failed to fetch branches');
} finally {
setIsLoading(false);
}
};
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
setSelectedRepository(repo);
await fetchBranches(repo);
};
const formatGitUrl = (url: string): string => {
// Remove any tree references and ensure .git extension
const baseUrl = url
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
.replace(/\/$/, '') // Remove trailing slash
.replace(/\.git$/, ''); // Remove .git if present
return `${baseUrl}.git`;
};
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
try {
const [owner, repo] = repoUrl
.replace(/\.git$/, '')
.split('/')
.slice(-2);
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
// Fetch repository tree
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
headers,
});
if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure');
}
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
// Calculate repository stats
let totalSize = 0;
let totalFiles = 0;
const languages: { [key: string]: number } = {};
let hasPackageJson = false;
let hasDependencies = false;
for (const file of treeData.tree) {
if (file.type === 'blob') {
totalFiles++;
if (file.size) {
totalSize += file.size;
}
// Check for package.json
if (file.path === 'package.json') {
hasPackageJson = true;
// Fetch package.json content to check dependencies
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
headers,
});
if (contentResponse.ok) {
const content = (await contentResponse.json()) as GitHubContent;
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
hasDependencies = !!(
packageJson.dependencies ||
packageJson.devDependencies ||
packageJson.peerDependencies
);
}
}
// Detect language based on file extension
const ext = file.path.split('.').pop()?.toLowerCase();
if (ext) {
languages[ext] = (languages[ext] || 0) + (file.size || 0);
}
}
}
const stats: RepositoryStats = {
totalFiles,
totalSize,
languages,
hasPackageJson,
hasDependencies,
};
setStats(stats);
return stats;
} catch (error) {
console.error('Error verifying repository:', error);
toast.error('Failed to verify repository');
return null;
}
};
const handleImport = async () => {
try {
let gitUrl: string;
if (activeTab === 'url' && customUrl) {
gitUrl = formatGitUrl(customUrl);
} else if (selectedRepository) {
gitUrl = formatGitUrl(selectedRepository.html_url);
if (selectedBranch) {
gitUrl = `${gitUrl}#${selectedBranch}`;
}
} else {
return;
}
// Verify repository before importing
const stats = await verifyRepository(gitUrl);
if (!stats) {
return;
}
setCurrentStats(stats);
setPendingGitUrl(gitUrl);
setShowStatsDialog(true);
} catch (error) {
console.error('Error preparing repository:', error);
toast.error('Failed to prepare repository. Please try again.');
}
};
const handleStatsConfirm = () => {
setShowStatsDialog(false);
if (pendingGitUrl) {
onSelect(pendingGitUrl);
onClose();
}
};
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
let parsedValue: string | number | undefined = value;
if (key === 'stars' || key === 'forks') {
parsedValue = value ? parseInt(value, 10) : undefined;
}
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
handleSearch(searchQuery);
};
// Handle dialog close properly
const handleClose = () => {
setIsLoading(false); // Reset loading state
setSearchQuery(''); // Reset search
setSearchResults([]); // Reset results
onClose();
};
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Import GitHub Repository
</Dialog.Title>
<Dialog.Close
onClick={handleClose}
className={classNames(
'p-2 rounded-lg transition-all duration-200 ease-in-out',
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
)}
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</Dialog.Close>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
<span className="i-ph:book-bookmark" />
My Repos
</TabButton>
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
<span className="i-ph:magnifying-glass" />
Search
</TabButton>
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
<span className="i-ph:link" />
URL
</TabButton>
</div>
{activeTab === 'url' ? (
<div className="space-y-4">
<Input
placeholder="Enter repository URL"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={classNames('w-full', {
'border-red-500': false,
})}
/>
<button
onClick={handleImport}
disabled={!customUrl}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Repository
</button>
</div>
) : (
<>
{activeTab === 'search' && (
<div className="space-y-4 mb-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
handleSearch(e.target.value);
}}
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
/>
<button
onClick={() => setFilters({})}
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
<span className="i-ph:funnel-simple" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Filter by language..."
value={filters.language || ''}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value });
handleSearch(searchQuery);
}}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
<input
type="number"
placeholder="Min stars..."
value={filters.stars || ''}
onChange={(e) => handleFilterChange('stars', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
<input
type="number"
placeholder="Min forks..."
value={filters.forks || ''}
onChange={(e) => handleFilterChange('forks', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
)}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{selectedRepository ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedRepository(null)}
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
>
<span className="i-ph:arrow-left w-4 h-4" />
</button>
<h3 className="font-medium">{selectedRepository.full_name}</h3>
</div>
<div className="space-y-2">
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
{branches.map((branch) => (
<option
key={branch.name}
value={branch.name}
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
<button
onClick={handleImport}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Selected Branch
</button>
</div>
</div>
) : (
<RepositoryList
repos={activeTab === 'my-repos' ? repositories : searchResults}
isLoading={isLoading}
onSelect={handleRepoSelect}
activeTab={activeTab}
/>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
{currentStats && (
<StatsDialog
isOpen={showStatsDialog}
onClose={handleStatsConfirm}
onConfirm={handleStatsConfirm}
stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/>
)}
</Dialog.Root>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={classNames(
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
active
? 'bg-purple-500 text-white hover:bg-purple-600'
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
)}
>
{children}
</button>
);
}
function RepositoryList({
repos,
isLoading,
onSelect,
activeTab,
}: {
repos: GitHubRepoInfo[];
isLoading: boolean;
onSelect: (repo: GitHubRepoInfo) => void;
activeTab: string;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
<span className="i-ph:spinner animate-spin mr-2" />
Loading repositories...
</div>
);
}
if (repos.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
</div>
);
}
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
}
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
return (
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
</div>
<button
onClick={onSelect}
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
>
<span className="i-ph:download-simple w-4 h-4" />
Import
</button>
</div>
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
{repo.language && (
<span className="flex items-center gap-1">
<span className="i-ph:code" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1">
<span className="i-ph:star" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<span className="i-ph:clock" />
{new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
export interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
public_gists: number;
created_at: string;
updated_at: string;
}
export interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
languages_url: string;
}
export interface GitHubOrganization {
login: string;
avatar_url: string;
description: string;
html_url: string;
}
export interface GitHubEvent {
id: string;
type: string;
created_at: string;
repo: {
name: string;
url: string;
};
payload: {
action?: string;
ref?: string;
ref_type?: string;
description?: string;
};
}
export interface GitHubLanguageStats {
[key: string]: number;
}
export interface GitHubStats {
repos: GitHubRepoInfo[];
totalStars: number;
totalForks: number;
organizations: GitHubOrganization[];
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
}
export interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
}
export interface GitHubTokenInfo {
token: string;
scope: string[];
avatar_url: string;
name: string | null;
created_at: string;
followers: number;
}
export interface GitHubRateLimits {
limit: number;
remaining: number;
reset: Date;
used: number;
}
export interface GitHubAuthState {
username: string;
tokenInfo: GitHubTokenInfo | null;
isConnected: boolean;
isVerifying: boolean;
isLoadingRepos: boolean;
rateLimits?: GitHubRateLimits;
}

View File

@@ -0,0 +1,452 @@
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
import { db, getAll, deleteById } from '~/lib/persistence';
export default function DataTab() {
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
const [isImportingKeys, setIsImportingKeys] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
const handleExportAllChats = async () => {
try {
if (!db) {
throw new Error('Database not initialized');
}
// Get all chats from IndexedDB
const allChats = await getAll(db);
const exportData = {
chats: allChats,
exportDate: new Date().toISOString(),
};
// Download as JSON
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bolt-chats-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Chats exported successfully');
} catch (error) {
console.error('Export error:', error);
toast.error('Failed to export chats');
}
};
const handleExportSettings = () => {
try {
const settings = {
userProfile: localStorage.getItem('bolt_user_profile'),
settings: localStorage.getItem('bolt_settings'),
exportDate: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bolt-settings-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Settings exported successfully');
} catch (error) {
console.error('Export error:', error);
toast.error('Failed to export settings');
}
};
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const content = await file.text();
const settings = JSON.parse(content);
if (settings.userProfile) {
localStorage.setItem('bolt_user_profile', settings.userProfile);
}
if (settings.settings) {
localStorage.setItem('bolt_settings', settings.settings);
}
window.location.reload(); // Reload to apply settings
toast.success('Settings imported successfully');
} catch (error) {
console.error('Import error:', error);
toast.error('Failed to import settings');
}
};
const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setIsImportingKeys(true);
try {
const content = await file.text();
const keys = JSON.parse(content);
// Validate and save each key
Object.entries(keys).forEach(([key, value]) => {
if (typeof value !== 'string') {
throw new Error(`Invalid value for key: ${key}`);
}
localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
});
toast.success('API keys imported successfully');
} catch (error) {
console.error('Error importing API keys:', error);
toast.error('Failed to import API keys');
} finally {
setIsImportingKeys(false);
if (apiKeyFileInputRef.current) {
apiKeyFileInputRef.current.value = '';
}
}
};
const handleDownloadTemplate = () => {
setIsDownloadingTemplate(true);
try {
const template = {
Anthropic_API_KEY: '',
OpenAI_API_KEY: '',
Google_API_KEY: '',
Groq_API_KEY: '',
HuggingFace_API_KEY: '',
OpenRouter_API_KEY: '',
Deepseek_API_KEY: '',
Mistral_API_KEY: '',
OpenAILike_API_KEY: '',
Together_API_KEY: '',
xAI_API_KEY: '',
Perplexity_API_KEY: '',
Cohere_API_KEY: '',
AzureOpenAI_API_KEY: '',
OPENAI_LIKE_API_BASE_URL: '',
LMSTUDIO_API_BASE_URL: '',
OLLAMA_API_BASE_URL: '',
TOGETHER_API_BASE_URL: '',
};
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-api-keys-template.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Template downloaded successfully');
} catch (error) {
console.error('Error downloading template:', error);
toast.error('Failed to download template');
} finally {
setIsDownloadingTemplate(false);
}
};
const handleResetSettings = async () => {
setIsResetting(true);
try {
// Clear all stored settings from localStorage
localStorage.removeItem('bolt_user_profile');
localStorage.removeItem('bolt_settings');
localStorage.removeItem('bolt_chat_history');
// Clear all data from IndexedDB
if (!db) {
throw new Error('Database not initialized');
}
// Get all chats and delete them
const chats = await getAll(db as IDBDatabase);
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
await Promise.all(deletePromises);
// Close the dialog first
setShowResetInlineConfirm(false);
// Then reload and show success message
window.location.reload();
toast.success('Settings reset successfully');
} catch (error) {
console.error('Reset error:', error);
setShowResetInlineConfirm(false);
toast.error('Failed to reset settings');
} finally {
setIsResetting(false);
}
};
const handleDeleteAllChats = async () => {
setIsDeleting(true);
try {
// Clear chat history from localStorage
localStorage.removeItem('bolt_chat_history');
// Clear chats from IndexedDB
if (!db) {
throw new Error('Database not initialized');
}
// Get all chats and delete them one by one
const chats = await getAll(db as IDBDatabase);
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
await Promise.all(deletePromises);
// Close the dialog first
setShowDeleteInlineConfirm(false);
// Then show the success message
toast.success('Chat history deleted successfully');
} catch (error) {
console.error('Delete error:', error);
setShowDeleteInlineConfirm(false);
toast.error('Failed to delete chat history');
} finally {
setIsDeleting(false);
}
};
return (
<div className="space-y-6">
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
{/* Reset Settings Dialog */}
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
<Dialog showCloseButton={false} className="z-[1000]">
<div className="p-6">
<div className="flex items-center gap-3">
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
<DialogTitle>Reset All Settings?</DialogTitle>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-2">
This will reset all your settings to their default values. This action cannot be undone.
</p>
<div className="flex justify-end items-center gap-3 mt-6">
<DialogClose asChild>
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
Cancel
</button>
</DialogClose>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
onClick={handleResetSettings}
disabled={isResetting}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isResetting ? (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
) : (
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
)}
Reset Settings
</motion.button>
</div>
</div>
</Dialog>
</DialogRoot>
{/* Delete Confirmation Dialog */}
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
<Dialog showCloseButton={false} className="z-[1000]">
<div className="p-6">
<div className="flex items-center gap-3">
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
<DialogTitle>Delete All Chats?</DialogTitle>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-2">
This will permanently delete all your chat history. This action cannot be undone.
</p>
<div className="flex justify-end items-center gap-3 mt-6">
<DialogClose asChild>
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
Cancel
</button>
</DialogClose>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
onClick={handleDeleteAllChats}
disabled={isDeleting}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isDeleting ? (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
) : (
<div className="i-ph:trash w-4 h-4" />
)}
Delete All
</motion.button>
</div>
</div>
</Dialog>
</DialogRoot>
{/* Chat History Section */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
<div className="flex gap-4">
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleExportAllChats}
>
<div className="i-ph:download-simple w-4 h-4" />
Export All Chats
</motion.button>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setShowDeleteInlineConfirm(true)}
>
<div className="i-ph:trash w-4 h-4" />
Delete All Chats
</motion.button>
</div>
</motion.div>
{/* Settings Backup Section */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Export your settings to a JSON file or import settings from a previously exported file.
</p>
<div className="flex gap-4">
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleExportSettings}
>
<div className="i-ph:download-simple w-4 h-4" />
Export Settings
</motion.button>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => fileInputRef.current?.click()}
>
<div className="i-ph:upload-simple w-4 h-4" />
Import Settings
</motion.button>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setShowResetInlineConfirm(true)}
>
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
Reset Settings
</motion.button>
</div>
</motion.div>
{/* API Keys Management Section */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Import API keys from a JSON file or download a template to fill in your keys.
</p>
<div className="flex gap-4">
<input
ref={apiKeyFileInputRef}
type="file"
accept=".json"
onChange={handleImportAPIKeys}
className="hidden"
/>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleDownloadTemplate}
disabled={isDownloadingTemplate}
>
{isDownloadingTemplate ? (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
) : (
<div className="i-ph:download-simple w-4 h-4" />
)}
Download Template
</motion.button>
<motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => apiKeyFileInputRef.current?.click()}
disabled={isImportingKeys}
>
{isImportingKeys ? (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
) : (
<div className="i-ph:upload-simple w-4 h-4" />
)}
Import API Keys
</motion.button>
</div>
</motion.div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
// Remove unused imports
import React, { memo, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
import { classNames } from '~/utils/classNames';
import { toast } from 'react-toastify';
import { PromptLibrary } from '~/lib/common/prompt-library';
interface FeatureToggle {
id: string;
title: string;
description: string;
icon: string;
enabled: boolean;
beta?: boolean;
experimental?: boolean;
tooltip?: string;
}
const FeatureCard = memo(
({
feature,
index,
onToggle,
}: {
feature: FeatureToggle;
index: number;
onToggle: (id: string, enabled: boolean) => void;
}) => (
<motion.div
key={feature.id}
layoutId={feature.id}
className={classNames(
'relative group cursor-pointer',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-colors duration-200',
'rounded-lg overflow-hidden',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={classNames(feature.icon, 'w-5 h-5 text-bolt-elements-textSecondary')} />
<div className="flex items-center gap-2">
<h4 className="font-medium text-bolt-elements-textPrimary">{feature.title}</h4>
{feature.beta && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">Beta</span>
)}
{feature.experimental && (
<span className="px-2 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-500 font-medium">
Experimental
</span>
)}
</div>
</div>
<Switch checked={feature.enabled} onCheckedChange={(checked) => onToggle(feature.id, checked)} />
</div>
<p className="mt-2 text-sm text-bolt-elements-textSecondary">{feature.description}</p>
{feature.tooltip && <p className="mt-1 text-xs text-bolt-elements-textTertiary">{feature.tooltip}</p>}
</div>
</motion.div>
),
);
const FeatureSection = memo(
({
title,
features,
icon,
description,
onToggleFeature,
}: {
title: string;
features: FeatureToggle[];
icon: string;
description: string;
onToggleFeature: (id: string, enabled: boolean) => void;
}) => (
<motion.div
layout
className="flex flex-col gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center gap-3">
<div className={classNames(icon, 'text-xl text-purple-500')} />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">{title}</h3>
<p className="text-sm text-bolt-elements-textSecondary">{description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{features.map((feature, index) => (
<FeatureCard key={feature.id} feature={feature} index={index} onToggle={onToggleFeature} />
))}
</div>
</motion.div>
),
);
export default function FeaturesTab() {
const {
autoSelectTemplate,
isLatestBranch,
contextOptimizationEnabled,
eventLogs,
setAutoSelectTemplate,
enableLatestBranch,
enableContextOptimization,
setEventLogs,
setPromptId,
promptId,
} = useSettings();
// Enable features by default on first load
React.useEffect(() => {
// Only set defaults if values are undefined
if (isLatestBranch === undefined) {
enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
}
if (contextOptimizationEnabled === undefined) {
enableContextOptimization(true); // Default: ON - Enable context optimization
}
if (autoSelectTemplate === undefined) {
setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
}
if (promptId === undefined) {
setPromptId('default'); // Default: 'default'
}
if (eventLogs === undefined) {
setEventLogs(true); // Default: ON - Enable event logging
}
}, []); // Only run once on component mount
const handleToggleFeature = useCallback(
(id: string, enabled: boolean) => {
switch (id) {
case 'latestBranch': {
enableLatestBranch(enabled);
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'autoSelectTemplate': {
setAutoSelectTemplate(enabled);
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'contextOptimization': {
enableContextOptimization(enabled);
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'eventLogs': {
setEventLogs(enabled);
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
break;
}
default:
break;
}
},
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
);
const features = {
stable: [
{
id: 'latestBranch',
title: 'Main Branch Updates',
description: 'Get the latest updates from the main branch',
icon: 'i-ph:git-branch',
enabled: isLatestBranch,
tooltip: 'Enabled by default to receive updates from the main development branch',
},
{
id: 'autoSelectTemplate',
title: 'Auto Select Template',
description: 'Automatically select starter template',
icon: 'i-ph:selection',
enabled: autoSelectTemplate,
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
},
{
id: 'contextOptimization',
title: 'Context Optimization',
description: 'Optimize context for better responses',
icon: 'i-ph:brain',
enabled: contextOptimizationEnabled,
tooltip: 'Enabled by default for improved AI responses',
},
{
id: 'eventLogs',
title: 'Event Logging',
description: 'Enable detailed event logging and history',
icon: 'i-ph:list-bullets',
enabled: eventLogs,
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
},
],
beta: [],
};
return (
<div className="flex flex-col gap-8">
<FeatureSection
title="Core Features"
features={features.stable}
icon="i-ph:check-circle"
description="Essential features that are enabled by default for optimal performance"
onToggleFeature={handleToggleFeature}
/>
{features.beta.length > 0 && (
<FeatureSection
title="Beta Features"
features={features.beta}
icon="i-ph:test-tube"
description="New features that are ready for testing but may have some rough edges"
onToggleFeature={handleToggleFeature}
/>
)}
<motion.div
layout
className={classNames(
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'rounded-lg p-4',
'group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center gap-4">
<div
className={classNames(
'p-2 rounded-lg text-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-colors duration-200',
'text-purple-500',
)}
>
<div className="i-ph:book" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
Prompt Library
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
Choose a prompt from the library to use as the system prompt
</p>
</div>
<select
value={promptId}
onChange={(e) => {
setPromptId(e.target.value);
toast.success('Prompt template updated');
}}
className={classNames(
'p-2 rounded-lg text-sm min-w-[200px]',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'group-hover:border-purple-500/30',
'transition-all duration-200',
)}
>
{PromptLibrary.getList().map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { logStore } from '~/lib/stores/logs';
import { useStore } from '@nanostores/react';
import { formatDistanceToNow } from 'date-fns';
import { classNames } from '~/utils/classNames';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
interface NotificationDetails {
type?: string;
message?: string;
currentVersion?: string;
latestVersion?: string;
branch?: string;
updateUrl?: string;
}
type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
const NotificationsTab = () => {
const [filter, setFilter] = useState<FilterType>('all');
const logs = useStore(logStore.logs);
useEffect(() => {
const startTime = performance.now();
return () => {
const duration = performance.now() - startTime;
logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
};
}, []);
const handleClearNotifications = () => {
const count = Object.keys(logs).length;
logStore.logInfo('Cleared notifications', {
type: 'notification_clear',
message: `Cleared ${count} notifications`,
clearedCount: count,
component: 'notifications',
});
logStore.clearLogs();
};
const handleUpdateAction = (updateUrl: string) => {
logStore.logInfo('Update link clicked', {
type: 'update_click',
message: 'User clicked update link',
updateUrl,
component: 'notifications',
});
window.open(updateUrl, '_blank');
};
const handleFilterChange = (newFilter: FilterType) => {
logStore.logInfo('Notification filter changed', {
type: 'filter_change',
message: `Filter changed to ${newFilter}`,
previousFilter: filter,
newFilter,
component: 'notifications',
});
setFilter(newFilter);
};
const filteredLogs = Object.values(logs)
.filter((log) => {
if (filter === 'all') {
return true;
}
if (filter === 'update') {
return log.details?.type === 'update';
}
if (filter === 'system') {
return log.category === 'system';
}
if (filter === 'provider') {
return log.category === 'provider';
}
if (filter === 'network') {
return log.category === 'network';
}
return log.level === filter;
})
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const getNotificationStyle = (level: string, type?: string) => {
if (type === 'update') {
return {
icon: 'i-ph:arrow-circle-up',
color: 'text-purple-500 dark:text-purple-400',
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
};
}
switch (level) {
case 'error':
return {
icon: 'i-ph:warning-circle',
color: 'text-red-500 dark:text-red-400',
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
};
case 'warning':
return {
icon: 'i-ph:warning',
color: 'text-yellow-500 dark:text-yellow-400',
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
};
case 'info':
return {
icon: 'i-ph:info',
color: 'text-blue-500 dark:text-blue-400',
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
};
default:
return {
icon: 'i-ph:bell',
color: 'text-gray-500 dark:text-gray-400',
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
};
}
};
const renderNotificationDetails = (details: NotificationDetails) => {
if (details.type === 'update') {
return (
<div className="flex flex-col gap-2">
<p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
<p>Current Version: {details.currentVersion}</p>
<p>Latest Version: {details.latestVersion}</p>
<p>Branch: {details.branch}</p>
</div>
<button
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
className={classNames(
'mt-2 inline-flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm font-medium',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-gray-900 dark:text-white',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span className="i-ph:git-branch text-lg" />
View Changes
</button>
</div>
);
}
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
};
const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
{ id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
{ id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
{ id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
{ id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
{ id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
{ id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
{ id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
{ id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
];
return (
<div className="flex h-full flex-col gap-6">
<div className="flex items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={classNames(
'flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm text-gray-900 dark:text-white',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span
className={classNames('text-lg', filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel')}
style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
/>
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
sideOffset={5}
align="start"
side="bottom"
>
{filterOptions.map((option) => (
<DropdownMenu.Item
key={option.id}
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onClick={() => handleFilterChange(option.id)}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
style={{ color: option.color }}
/>
</div>
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
onClick={handleClearNotifications}
className={classNames(
'group flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm text-gray-900 dark:text-white',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
Clear All
</button>
</div>
<div className="flex flex-col gap-4">
{filteredLogs.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col items-center justify-center gap-4',
'rounded-lg p-8 text-center',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
</div>
</motion.div>
) : (
filteredLogs.map((log) => {
const style = getNotificationStyle(log.level, log.details?.type);
return (
<motion.div
key={log.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col gap-2',
'rounded-lg p-4',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
style.bg,
'transition-all duration-200',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<span className={classNames('text-lg', style.icon, style.color)} />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Category: {log.category}
{log.subCategory ? ` > ${log.subCategory}` : ''}
</p>
</div>
</div>
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</time>
</div>
</motion.div>
);
})
)}
</div>
</div>
);
};
export default NotificationsTab;

View File

@@ -0,0 +1,181 @@
import { useState, useCallback } from 'react';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { profileStore, updateProfile } from '~/lib/stores/profile';
import { toast } from 'react-toastify';
import { debounce } from '~/utils/debounce';
export default function ProfileTab() {
const profile = useStore(profileStore);
const [isUploading, setIsUploading] = useState(false);
// Create debounced update functions
const debouncedUpdate = useCallback(
debounce((field: 'username' | 'bio', value: string) => {
updateProfile({ [field]: value });
toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
}, 1000),
[],
);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
try {
setIsUploading(true);
// Convert the file to base64
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
updateProfile({ avatar: base64String });
setIsUploading(false);
toast.success('Profile picture updated');
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
setIsUploading(false);
toast.error('Failed to update profile picture');
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error uploading avatar:', error);
setIsUploading(false);
toast.error('Failed to update profile picture');
}
};
const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
// Update the store immediately for UI responsiveness
updateProfile({ [field]: value });
// Debounce the toast notification
debouncedUpdate(field, value);
};
return (
<div className="max-w-2xl mx-auto">
<div className="space-y-6">
{/* Personal Information Section */}
<div>
{/* Avatar Upload */}
<div className="flex items-start gap-6 mb-8">
<div
className={classNames(
'w-24 h-24 rounded-full overflow-hidden',
'bg-gray-100 dark:bg-gray-800/50',
'flex items-center justify-center',
'ring-1 ring-gray-200 dark:ring-gray-700',
'relative group',
'transition-all duration-300 ease-out',
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
'hover:shadow-lg hover:shadow-purple-500/10',
)}
>
{profile.avatar ? (
<img
src={profile.avatar}
alt="Profile"
className={classNames(
'w-full h-full object-cover',
'transition-all duration-300 ease-out',
'group-hover:scale-105 group-hover:brightness-90',
)}
/>
) : (
<div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
)}
<label
className={classNames(
'absolute inset-0',
'flex items-center justify-center',
'bg-black/0 group-hover:bg-black/40',
'cursor-pointer transition-all duration-300 ease-out',
isUploading ? 'cursor-wait' : '',
)}
>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isUploading}
/>
{isUploading ? (
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
) : (
<div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
)}
</label>
</div>
<div className="flex-1 pt-1">
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
Profile Picture
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
</div>
</div>
{/* Username Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
<div className="relative group">
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
<div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<input
type="text"
value={profile.username}
onChange={(e) => handleProfileUpdate('username', e.target.value)}
className={classNames(
'w-full pl-11 pr-4 py-2.5 rounded-xl',
'bg-white dark:bg-gray-800/50',
'border border-gray-200 dark:border-gray-700/50',
'text-gray-900 dark:text-white',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
'transition-all duration-300 ease-out',
)}
placeholder="Enter your username"
/>
</div>
</div>
{/* Bio Input */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
<div className="relative group">
<div className="absolute left-3.5 top-3">
<div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<textarea
value={profile.bio}
onChange={(e) => handleProfileUpdate('bio', e.target.value)}
className={classNames(
'w-full pl-11 pr-4 py-2.5 rounded-xl',
'bg-white dark:bg-gray-800/50',
'border border-gray-200 dark:border-gray-700/50',
'text-gray-900 dark:text-white',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
'transition-all duration-300 ease-out',
'resize-none',
'h-32',
)}
placeholder="Tell us about yourself"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,305 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { toast } from 'react-toastify';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
import { BsRobot, BsCloud } from 'react-icons/bs';
import { TbBrain, TbCloudComputing } from 'react-icons/tb';
import { BiCodeBlock, BiChip } from 'react-icons/bi';
import { FaCloud, FaBrain } from 'react-icons/fa';
import type { IconType } from 'react-icons';
// Add type for provider names to ensure type safety
type ProviderName =
| 'AmazonBedrock'
| 'Anthropic'
| 'Cohere'
| 'Deepseek'
| 'Google'
| 'Groq'
| 'HuggingFace'
| 'Hyperbolic'
| 'Mistral'
| 'OpenAI'
| 'OpenRouter'
| 'Perplexity'
| 'Together'
| 'XAI';
// Update the PROVIDER_ICONS type to use the ProviderName type
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
AmazonBedrock: SiAmazon,
Anthropic: FaBrain,
Cohere: BiChip,
Deepseek: BiCodeBlock,
Google: SiGoogle,
Groq: BsCloud,
HuggingFace: SiHuggingface,
Hyperbolic: TbCloudComputing,
Mistral: TbBrain,
OpenAI: SiOpenai,
OpenRouter: FaCloud,
Perplexity: SiPerplexity,
Together: BsCloud,
XAI: BsRobot,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
Anthropic: 'Access Claude and other Anthropic models',
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
};
const CloudProvidersTab = () => {
const settings = useSettings();
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
// Load and filter providers
useEffect(() => {
const newFilteredProviders = Object.entries(settings.providers || {})
.filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
.map(([key, value]) => ({
name: key,
settings: value.settings,
staticModels: value.staticModels || [],
getDynamicModels: value.getDynamicModels,
getApiKeyLink: value.getApiKeyLink,
labelForGetApiKey: value.labelForGetApiKey,
icon: value.icon,
}));
const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
setFilteredProviders(sorted);
// Update category enabled state
const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
setCategoryEnabled(allEnabled);
}, [settings.providers]);
const handleToggleCategory = useCallback(
(enabled: boolean) => {
// Update all providers
filteredProviders.forEach((provider) => {
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
setCategoryEnabled(enabled);
toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
},
[filteredProviders, settings],
);
const handleToggleProvider = useCallback(
(provider: IProviderConfig, enabled: boolean) => {
// Update the provider settings in the store
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
toast.success(`${provider.name} enabled`);
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
toast.success(`${provider.name} disabled`);
}
},
[settings],
);
const handleUpdateBaseUrl = useCallback(
(provider: IProviderConfig, baseUrl: string) => {
const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
// Update the provider settings in the store
settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
});
toast.success(`${provider.name} base URL updated`);
setEditingProvider(null);
},
[settings],
);
return (
<div className="space-y-6">
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
'text-purple-500',
)}
>
<TbCloudComputing className="w-5 h-5" />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Cloud Providers</h4>
<p className="text-sm text-bolt-elements-textSecondary">Connect to cloud-based AI models and services</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">Enable All Cloud</span>
<Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredProviders.map((provider, index) => (
<motion.div
key={provider.name}
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden group',
'flex flex-col',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
<div className="absolute top-0 right-0 p-2 flex gap-1">
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.span
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Configurable
</motion.span>
)}
</div>
<div className="flex items-start gap-4 p-4">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-full h-full',
'aria-label': `${provider.name} logo`,
})}
</div>
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4 mb-2">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{provider.name}
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
(URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
? 'Configure custom endpoint for this provider'
: 'Standard AI provider integration')}
</p>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
/>
</div>
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center gap-2 mt-4">
{editingProvider === provider.name ? (
<input
type="text"
defaultValue={provider.settings.baseUrl}
placeholder={`Enter ${provider.name} base URL`}
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateBaseUrl(provider, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingProvider(null);
}
}}
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
autoFocus
/>
) : (
<div
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
onClick={() => setEditingProvider(provider.name)}
>
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:link text-sm" />
<span className="group-hover/url:text-purple-500 transition-colors">
{provider.settings.baseUrl || 'Click to set base URL'}
</span>
</div>
</div>
)}
</div>
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
<div className="mt-2 text-xs text-green-500">
<div className="flex items-center gap-1">
<div className="i-ph:info" />
<span>Environment URL set in .env file</span>
</div>
</div>
)}
</motion.div>
)}
</div>
</div>
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: provider.settings.enabled ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
</div>
</motion.div>
</div>
);
};
export default CloudProvidersTab;

View File

@@ -0,0 +1,777 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { BsRobot } from 'react-icons/bs';
import type { IconType } from 'react-icons';
import { BiChip } from 'react-icons/bi';
import { TbBrandOpenai } from 'react-icons/tb';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { useToast } from '~/components/ui/use-toast';
import { Progress } from '~/components/ui/Progress';
import OllamaModelInstaller from './OllamaModelInstaller';
// Add type for provider names to ensure type safety
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
// Update the PROVIDER_ICONS type to use the ProviderName type
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
Ollama: BsRobot,
LMStudio: BsRobot,
OpenAILike: TbBrandOpenai,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
Ollama: 'Run open-source models locally on your machine',
LMStudio: 'Local model inference with LM Studio',
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
};
// Add a constant for the Ollama API base URL
const OLLAMA_API_URL = 'http://127.0.0.1:11434';
interface OllamaModel {
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
error?: string;
newDigest?: string;
progress?: {
current: number;
total: number;
status: string;
};
}
interface OllamaPullResponse {
status: string;
completed?: number;
total?: number;
digest?: string;
}
const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
return (
typeof data === 'object' &&
data !== null &&
'status' in data &&
typeof (data as OllamaPullResponse).status === 'string'
);
};
export default function LocalProvidersTab() {
const { providers, updateProviderSettings } = useSettings();
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState(false);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const { toast } = useToast();
// Effect to filter and sort providers
useEffect(() => {
const newFilteredProviders = Object.entries(providers || {})
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
.map(([key, value]) => {
const provider = value as IProviderConfig;
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
// Set base URL if provided by environment
if (envUrl && !provider.settings.baseUrl) {
updateProviderSettings(key, {
...provider.settings,
baseUrl: envUrl,
});
}
return {
name: key,
settings: {
...provider.settings,
baseUrl: provider.settings.baseUrl || envUrl,
},
staticModels: provider.staticModels || [],
getDynamicModels: provider.getDynamicModels,
getApiKeyLink: provider.getApiKeyLink,
labelForGetApiKey: provider.labelForGetApiKey,
icon: provider.icon,
} as IProviderConfig;
});
// Custom sort function to ensure LMStudio appears before OpenAILike
const sorted = newFilteredProviders.sort((a, b) => {
if (a.name === 'LMStudio') {
return -1;
}
if (b.name === 'LMStudio') {
return 1;
}
if (a.name === 'OpenAILike') {
return 1;
}
if (b.name === 'OpenAILike') {
return -1;
}
return a.name.localeCompare(b.name);
});
setFilteredProviders(sorted);
}, [providers, updateProviderSettings]);
// Add effect to update category toggle state based on provider states
useEffect(() => {
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
setCategoryEnabled(newCategoryState);
}, [filteredProviders]);
// Fetch Ollama models when enabled
useEffect(() => {
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
if (ollamaProvider?.settings.enabled) {
fetchOllamaModels();
}
}, [filteredProviders]);
const fetchOllamaModels = async () => {
try {
setIsLoadingModels(true);
const response = await fetch('http://127.0.0.1:11434/api/tags');
const data = (await response.json()) as { models: OllamaModel[] };
setOllamaModels(
data.models.map((model) => ({
...model,
status: 'idle' as const,
})),
);
} catch (error) {
console.error('Error fetching Ollama models:', error);
} finally {
setIsLoadingModels(false);
}
};
const updateOllamaModel = async (modelName: string): Promise<boolean> => {
try {
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to update ${modelName}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response reader available');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const rawData = JSON.parse(line);
if (!isOllamaPullResponse(rawData)) {
console.error('Invalid response format:', rawData);
continue;
}
setOllamaModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: rawData.completed || 0,
total: rawData.total || 0,
status: rawData.status,
},
newDigest: rawData.digest,
}
: m,
),
);
}
}
const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
const updatedModel = updatedData.models.find((m) => m.name === modelName);
return updatedModel !== undefined;
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return false;
}
};
const handleToggleCategory = useCallback(
async (enabled: boolean) => {
filteredProviders.forEach((provider) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
},
[filteredProviders, updateProviderSettings],
);
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
updateProviderSettings(provider.name, {
...provider.settings,
enabled,
});
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
toast(`${provider.name} enabled`);
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
toast(`${provider.name} disabled`);
}
};
const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
updateProviderSettings(provider.name, {
...provider.settings,
baseUrl: newBaseUrl,
});
toast(`${provider.name} base URL updated`);
setEditingProvider(null);
};
const handleUpdateOllamaModel = async (modelName: string) => {
const updateSuccess = await updateOllamaModel(modelName);
if (updateSuccess) {
toast(`Updated ${modelName}`);
} else {
toast(`Failed to update ${modelName}`);
}
};
const handleDeleteOllamaModel = async (modelName: string) => {
try {
const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to delete ${modelName}`);
}
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
toast(`Deleted ${modelName}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error deleting ${modelName}:`, errorMessage);
toast(`Failed to delete ${modelName}`);
}
};
// Update model details display
const ModelDetails = ({ model }: { model: OllamaModel }) => (
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<div className="flex items-center gap-1">
<div className="i-ph:code text-purple-500" />
<span>{model.digest.substring(0, 7)}</span>
</div>
{model.details && (
<>
<div className="flex items-center gap-1">
<div className="i-ph:database text-purple-500" />
<span>{model.details.parameter_size}</span>
</div>
<div className="flex items-center gap-1">
<div className="i-ph:cube text-purple-500" />
<span>{model.details.quantization_level}</span>
</div>
</>
)}
</div>
);
// Update model actions to not use Tooltip
const ModelActions = ({
model,
onUpdate,
onDelete,
}: {
model: OllamaModel;
onUpdate: () => void;
onDelete: () => void;
}) => (
<div className="flex items-center gap-2">
<motion.button
onClick={onUpdate}
disabled={model.status === 'updating'}
className={classNames(
'rounded-lg p-2',
'bg-purple-500/10 text-purple-500',
'hover:bg-purple-500/20',
'transition-all duration-200',
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Update model"
>
{model.status === 'updating' ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-sm">Updating...</span>
</div>
) : (
<div className="i-ph:arrows-clockwise text-lg" />
)}
</motion.button>
<motion.button
onClick={onDelete}
disabled={model.status === 'updating'}
className={classNames(
'rounded-lg p-2',
'bg-red-500/10 text-red-500',
'hover:bg-red-500/20',
'transition-all duration-200',
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Delete model"
>
<div className="i-ph:trash text-lg" />
</motion.button>
</div>
);
return (
<div
className={classNames(
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
role="region"
aria-label="Local Providers Configuration"
>
<motion.div
className="space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header section */}
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
<div className="flex items-center gap-3">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-purple-500/10 text-purple-500',
)}
whileHover={{ scale: 1.05 }}
>
<BiChip className="w-6 h-6" />
</motion.div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
</div>
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
<Switch
checked={categoryEnabled}
onCheckedChange={handleToggleCategory}
aria-label="Toggle all local providers"
/>
</div>
</div>
{/* Ollama Section */}
{filteredProviders
.filter((provider) => provider.name === 'Ollama')
.map((provider) => (
<motion.div
key={provider.name}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-xl',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200 p-5',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.01 }}
>
{/* Provider Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<motion.div
className={classNames(
'w-12 h-12 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1, rotate: 5 }}
>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-7 h-7',
'aria-label': `${provider.name} icon`,
})}
</motion.div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-1">
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
</p>
</div>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
aria-label={`Toggle ${provider.name} provider`}
/>
</div>
{/* URL Configuration Section */}
<AnimatePresence>
{provider.settings.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4"
>
<div className="flex flex-col gap-2">
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
{editingProvider === provider.name ? (
<input
type="text"
defaultValue={provider.settings.baseUrl || OLLAMA_API_URL}
placeholder="Enter Ollama base URL"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateBaseUrl(provider, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingProvider(null);
}
}}
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
autoFocus
/>
) : (
<div
onClick={() => setEditingProvider(provider.name)}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
)}
>
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:link text-sm" />
<span>{provider.settings.baseUrl || OLLAMA_API_URL}</span>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Ollama Models Section */}
{provider.settings.enabled && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:cube-duotone text-purple-500" />
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
</div>
{isLoadingModels ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
</div>
) : (
<span className="text-sm text-bolt-elements-textSecondary">
{ollamaModels.length} models available
</span>
)}
</div>
<div className="space-y-3">
{isLoadingModels ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
/>
))}
</div>
) : ollamaModels.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
<p>No models installed yet</p>
<p className="text-sm text-bolt-elements-textTertiary px-1">
Browse models at{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
>
ollama.com/library
<div className="i-ph:arrow-square-out text-xs" />
</a>{' '}
and copy model names to install
</p>
</div>
) : (
ollamaModels.map((model) => (
<motion.div
key={model.name}
className={classNames(
'p-4 rounded-xl',
'bg-bolt-elements-background-depth-3',
'hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
)}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
<ModelStatusBadge status={model.status} />
</div>
<ModelDetails model={model} />
</div>
<ModelActions
model={model}
onUpdate={() => handleUpdateOllamaModel(model.name)}
onDelete={() => {
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
handleDeleteOllamaModel(model.name);
}
}}
/>
</div>
{model.progress && (
<div className="mt-3">
<Progress
value={Math.round((model.progress.current / model.progress.total) * 100)}
className="h-1"
/>
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
<span>{model.progress.status}</span>
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
</div>
</div>
)}
</motion.div>
))
)}
</div>
{/* Model Installation Section */}
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
</motion.div>
)}
</motion.div>
))}
{/* Other Providers Section */}
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredProviders
.filter((provider) => provider.name !== 'Ollama')
.map((provider, index) => (
<motion.div
key={provider.name}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-xl',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200 p-5',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.01 }}
>
{/* Provider Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<motion.div
className={classNames(
'w-12 h-12 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1, rotate: 5 }}
>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-7 h-7',
'aria-label': `${provider.name} icon`,
})}
</motion.div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<div className="flex gap-1">
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
Local
</span>
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
Configurable
</span>
)}
</div>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-1">
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
</p>
</div>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
aria-label={`Toggle ${provider.name} provider`}
/>
</div>
{/* URL Configuration Section */}
<AnimatePresence>
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4"
>
<div className="flex flex-col gap-2">
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
{editingProvider === provider.name ? (
<input
type="text"
defaultValue={provider.settings.baseUrl}
placeholder={`Enter ${provider.name} base URL`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateBaseUrl(provider, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingProvider(null);
}
}}
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
autoFocus
/>
) : (
<div
onClick={() => setEditingProvider(provider.name)}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
)}
>
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:link text-sm" />
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
);
}
// Helper component for model status badge
function ModelStatusBadge({ status }: { status?: string }) {
if (!status || status === 'idle') {
return null;
}
const statusConfig = {
updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
};
const config = statusConfig[status as keyof typeof statusConfig];
if (!config) {
return null;
}
return (
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
{config.label}
</span>
);
}

View File

@@ -0,0 +1,603 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { Progress } from '~/components/ui/Progress';
import { useToast } from '~/components/ui/use-toast';
import { useSettings } from '~/lib/hooks/useSettings';
interface OllamaModelInstallerProps {
onModelInstalled: () => void;
}
interface InstallProgress {
status: string;
progress: number;
downloadedSize?: string;
totalSize?: string;
speed?: string;
}
interface ModelInfo {
name: string;
desc: string;
size: string;
tags: string[];
installedVersion?: string;
latestVersion?: string;
needsUpdate?: boolean;
status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
}
const POPULAR_MODELS: ModelInfo[] = [
{
name: 'deepseek-coder:6.7b',
desc: "DeepSeek's code generation model",
size: '4.1GB',
tags: ['coding', 'popular'],
},
{
name: 'llama2:7b',
desc: "Meta's Llama 2 (7B parameters)",
size: '3.8GB',
tags: ['general', 'popular'],
},
{
name: 'mistral:7b',
desc: "Mistral's 7B model",
size: '4.1GB',
tags: ['general', 'popular'],
},
{
name: 'gemma:7b',
desc: "Google's Gemma model",
size: '4.0GB',
tags: ['general', 'new'],
},
{
name: 'codellama:7b',
desc: "Meta's Code Llama model",
size: '4.1GB',
tags: ['coding', 'popular'],
},
{
name: 'neural-chat:7b',
desc: "Intel's Neural Chat model",
size: '4.1GB',
tags: ['chat', 'popular'],
},
{
name: 'phi:latest',
desc: "Microsoft's Phi-2 model",
size: '2.7GB',
tags: ['small', 'fast'],
},
{
name: 'qwen:7b',
desc: "Alibaba's Qwen model",
size: '4.1GB',
tags: ['general'],
},
{
name: 'solar:10.7b',
desc: "Upstage's Solar model",
size: '6.1GB',
tags: ['large', 'powerful'],
},
{
name: 'openchat:7b',
desc: 'Open-source chat model',
size: '4.1GB',
tags: ['chat', 'popular'],
},
{
name: 'dolphin-phi:2.7b',
desc: 'Lightweight chat model',
size: '1.6GB',
tags: ['small', 'fast'],
},
{
name: 'stable-code:3b',
desc: 'Lightweight coding model',
size: '1.8GB',
tags: ['coding', 'small'],
},
];
function formatBytes(bytes: number): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatSpeed(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
// Add Ollama Icon SVG component
function OllamaIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
</svg>
);
}
export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
const [modelString, setModelString] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isInstalling, setIsInstalling] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
const { toast } = useToast();
const { providers } = useSettings();
// Get base URL from provider settings
const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
// Function to check installed models and their versions
const checkInstalledModels = async () => {
try {
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch installed models');
}
const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
const installedModels = data.models || [];
// Update models with installed versions
setModels((prevModels) =>
prevModels.map((model) => {
const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
if (installed) {
return {
...model,
installedVersion: installed.digest.substring(0, 8),
needsUpdate: installed.digest !== installed.latest,
latestVersion: installed.latest?.substring(0, 8),
};
}
return model;
}),
);
} catch (error) {
console.error('Error checking installed models:', error);
}
};
// Check installed models on mount and after installation
useEffect(() => {
checkInstalledModels();
}, [baseUrl]);
const handleCheckUpdates = async () => {
setIsChecking(true);
try {
await checkInstalledModels();
toast('Model versions checked');
} catch (err) {
console.error('Failed to check model versions:', err);
toast('Failed to check model versions');
} finally {
setIsChecking(false);
}
};
const filteredModels = models.filter((model) => {
const matchesSearch =
searchQuery === '' ||
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.desc.toLowerCase().includes(searchQuery.toLowerCase());
const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
return matchesSearch && matchesTags;
});
const handleInstallModel = async (modelToInstall: string) => {
if (!modelToInstall) {
return;
}
try {
setIsInstalling(true);
setInstallProgress({
status: 'Starting download...',
progress: 0,
downloadedSize: '0 B',
totalSize: 'Calculating...',
speed: '0 B/s',
});
setModelString('');
setSearchQuery('');
const response = await fetch(`${baseUrl}/api/pull`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelToInstall }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
let lastTime = Date.now();
let lastBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
try {
const data = JSON.parse(line);
if ('status' in data) {
const currentTime = Date.now();
const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
const bytesDiff = (data.completed || 0) - lastBytes;
const speed = bytesDiff / timeDiff;
setInstallProgress({
status: data.status,
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
downloadedSize: formatBytes(data.completed || 0),
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
speed: formatSpeed(speed),
});
lastTime = currentTime;
lastBytes = data.completed || 0;
}
} catch (err) {
console.error('Error parsing progress:', err);
}
}
}
toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
// Ensure we call onModelInstalled after successful installation
setTimeout(() => {
onModelInstalled();
}, 1000);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error installing ${modelToInstall}:`, errorMessage);
toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
} finally {
setIsInstalling(false);
setInstallProgress(null);
}
};
const handleUpdateModel = async (modelToUpdate: string) => {
try {
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
const response = await fetch(`${baseUrl}/api/pull`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelToUpdate }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
let lastTime = Date.now();
let lastBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
try {
const data = JSON.parse(line);
if ('status' in data) {
const currentTime = Date.now();
const timeDiff = (currentTime - lastTime) / 1000;
const bytesDiff = (data.completed || 0) - lastBytes;
const speed = bytesDiff / timeDiff;
setInstallProgress({
status: data.status,
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
downloadedSize: formatBytes(data.completed || 0),
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
speed: formatSpeed(speed),
});
lastTime = currentTime;
lastBytes = data.completed || 0;
}
} catch (err) {
console.error('Error parsing progress:', err);
}
}
}
toast('Successfully updated ' + modelToUpdate);
// Refresh model list after update
await checkInstalledModels();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error updating ${modelToUpdate}:`, errorMessage);
toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
} finally {
setInstallProgress(null);
}
};
const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
return (
<div className="space-y-6">
<div className="flex items-center justify-between pt-6">
<div className="flex items-center gap-3">
<OllamaIcon className="w-8 h-8 text-purple-500" />
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
</div>
</div>
<motion.button
onClick={handleCheckUpdates}
disabled={isChecking}
className={classNames(
'px-4 py-2 rounded-lg',
'bg-purple-500/10 text-purple-500',
'hover:bg-purple-500/20',
'transition-all duration-200',
'flex items-center gap-2',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isChecking ? (
<div className="i-ph:spinner-gap-bold animate-spin" />
) : (
<div className="i-ph:arrows-clockwise" />
)}
Check Updates
</motion.button>
</div>
<div className="flex gap-4">
<div className="flex-1">
<div className="space-y-1">
<input
type="text"
className={classNames(
'w-full px-4 py-3 rounded-xl',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
placeholder="Search models or enter custom model name..."
value={searchQuery || modelString}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
setModelString(value);
}}
disabled={isInstalling}
/>
<p className="text-sm text-bolt-elements-textSecondary px-1">
Browse models at{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
>
ollama.com/library
<div className="i-ph:arrow-square-out text-sm" />
</a>{' '}
and copy model names to install
</p>
</div>
</div>
<motion.button
onClick={() => handleInstallModel(modelString)}
disabled={!modelString || isInstalling}
className={classNames(
'rounded-lg px-4 py-2',
'bg-purple-500 text-white text-sm',
'hover:bg-purple-600',
'transition-all duration-200',
'flex items-center gap-2',
{ 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isInstalling ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span>Installing...</span>
</div>
) : (
<div className="flex items-center gap-2">
<OllamaIcon className="w-4 h-4" />
<span>Install Model</span>
</div>
)}
</motion.button>
</div>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => {
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
}}
className={classNames(
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
selectedTags.includes(tag)
? 'bg-purple-500 text-white'
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
)}
>
{tag}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-2">
{filteredModels.map((model) => (
<motion.div
key={model.name}
className={classNames(
'flex items-start gap-2 p-3 rounded-lg',
'bg-bolt-elements-background-depth-3',
'hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
'relative group',
)}
>
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="flex items-start justify-between">
<div>
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
</div>
<div className="text-right">
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
{model.installedVersion && (
<div className="mt-0.5 flex flex-col items-end gap-0.5">
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
{model.needsUpdate && model.latestVersion && (
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{model.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
>
{tag}
</span>
))}
</div>
<div className="flex gap-2">
{model.installedVersion ? (
model.needsUpdate ? (
<motion.button
onClick={() => handleUpdateModel(model.name)}
className={classNames(
'px-2 py-0.5 rounded-lg text-xs',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'transition-all duration-200',
'flex items-center gap-1',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise text-xs" />
Update
</motion.button>
) : (
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
)
) : (
<motion.button
onClick={() => handleInstallModel(model.name)}
className={classNames(
'px-2 py-0.5 rounded-lg text-xs',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'transition-all duration-200',
'flex items-center gap-1',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:download text-xs" />
Install
</motion.button>
)}
</div>
</div>
</div>
</motion.div>
))}
</div>
{installProgress && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
<div className="flex items-center gap-4">
<span className="text-bolt-elements-textTertiary">
{installProgress.downloadedSize} / {installProgress.totalSize}
</span>
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
</div>
</div>
<Progress value={installProgress.progress} className="h-1" />
</motion.div>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react';
import type { ServiceStatus } from './types';
import { ProviderStatusCheckerFactory } from './provider-factory';
export default function ServiceStatusTab() {
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const checkAllProviders = async () => {
try {
setLoading(true);
setError(null);
const providers = ProviderStatusCheckerFactory.getProviderNames();
const statuses: ServiceStatus[] = [];
for (const provider of providers) {
try {
const checker = ProviderStatusCheckerFactory.getChecker(provider);
const result = await checker.checkStatus();
statuses.push({
provider,
...result,
lastChecked: new Date().toISOString(),
});
} catch (err) {
console.error(`Error checking ${provider} status:`, err);
statuses.push({
provider,
status: 'degraded',
message: 'Unable to check service status',
incidents: ['Error checking service status'],
lastChecked: new Date().toISOString(),
});
}
}
setServiceStatuses(statuses);
} catch (err) {
console.error('Error checking provider statuses:', err);
setError('Failed to check service statuses');
} finally {
setLoading(false);
}
};
checkAllProviders();
// Set up periodic checks every 5 minutes
const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
const getStatusColor = (status: ServiceStatus['status']) => {
switch (status) {
case 'operational':
return 'text-green-500 dark:text-green-400';
case 'degraded':
return 'text-yellow-500 dark:text-yellow-400';
case 'down':
return 'text-red-500 dark:text-red-400';
default:
return 'text-gray-500 dark:text-gray-400';
}
};
const getStatusIcon = (status: ServiceStatus['status']) => {
switch (status) {
case 'operational':
return 'i-ph:check-circle';
case 'degraded':
return 'i-ph:warning';
case 'down':
return 'i-ph:x-circle';
default:
return 'i-ph:question';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
<div className="i-ph:warning w-8 h-8 mb-2" />
<p>{error}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4">
{serviceStatuses.map((service) => (
<div
key={service.provider}
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
<div className={`flex items-center ${getStatusColor(service.status)}`}>
<div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
<span className="capitalize">{service.status}</span>
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
{service.incidents && service.incidents.length > 0 && (
<div className="mt-2">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
{service.incidents.map((incident, index) => (
<li key={index}>{incident}</li>
))}
</ul>
</div>
)}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Last checked: {new Date(service.lastChecked).toLocaleString()}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
export abstract class BaseProviderChecker {
protected config: ProviderConfig;
constructor(config: ProviderConfig) {
this.config = config;
}
protected async checkApiEndpoint(
url: string,
headers?: Record<string, string>,
testModel?: string,
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const startTime = performance.now();
// Add common headers
const processedHeaders = {
'Content-Type': 'application/json',
...headers,
};
const response = await fetch(url, {
method: 'GET',
headers: processedHeaders,
signal: controller.signal,
});
const endTime = performance.now();
const responseTime = endTime - startTime;
clearTimeout(timeoutId);
const data = (await response.json()) as ApiResponse;
if (!response.ok) {
let errorMessage = `API returned status: ${response.status}`;
if (data.error?.message) {
errorMessage = data.error.message;
} else if (data.message) {
errorMessage = data.message;
}
return {
ok: false,
status: response.status,
message: errorMessage,
responseTime,
};
}
// Different providers have different model list formats
let models: string[] = [];
if (Array.isArray(data)) {
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
} else if (data.data && Array.isArray(data.data)) {
models = data.data.map((model) => model.id || model.name || '');
} else if (data.models && Array.isArray(data.models)) {
models = data.models.map((model) => model.id || model.name || '');
} else if (data.model) {
models = [data.model];
}
if (!testModel || models.length > 0) {
return {
ok: true,
status: response.status,
responseTime,
message: 'API key is valid',
};
}
if (testModel && !models.includes(testModel)) {
return {
ok: true,
status: 'model_not_found',
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
responseTime,
};
}
return {
ok: true,
status: response.status,
message: 'API key is valid',
responseTime,
};
} catch (error) {
console.error(`Error checking API endpoint ${url}:`, error);
return {
ok: false,
status: error instanceof Error ? error.message : 'Unknown error',
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
responseTime: 0,
};
}
}
protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
try {
const response = await fetch(url, {
mode: 'no-cors',
headers: {
Accept: 'text/html',
},
});
return response.type === 'opaque' ? 'reachable' : 'unreachable';
} catch (error) {
console.error(`Error checking ${url}:`, error);
return 'unreachable';
}
}
abstract checkStatus(): Promise<StatusCheckResult>;
}

View File

@@ -0,0 +1,154 @@
import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
import { BaseProviderChecker } from './base-provider';
import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
import { CohereStatusChecker } from './providers/cohere';
import { DeepseekStatusChecker } from './providers/deepseek';
import { GoogleStatusChecker } from './providers/google';
import { GroqStatusChecker } from './providers/groq';
import { HuggingFaceStatusChecker } from './providers/huggingface';
import { HyperbolicStatusChecker } from './providers/hyperbolic';
import { MistralStatusChecker } from './providers/mistral';
import { OpenRouterStatusChecker } from './providers/openrouter';
import { PerplexityStatusChecker } from './providers/perplexity';
import { TogetherStatusChecker } from './providers/together';
import { XAIStatusChecker } from './providers/xai';
export class ProviderStatusCheckerFactory {
private static _providerConfigs: Record<ProviderName, ProviderConfig> = {
AmazonBedrock: {
statusUrl: 'https://health.aws.amazon.com/health/status',
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
headers: {},
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
},
Cohere: {
statusUrl: 'https://status.cohere.com/',
apiUrl: 'https://api.cohere.ai/v1/models',
headers: {},
testModel: 'command',
},
Deepseek: {
statusUrl: 'https://status.deepseek.com/',
apiUrl: 'https://api.deepseek.com/v1/models',
headers: {},
testModel: 'deepseek-chat',
},
Google: {
statusUrl: 'https://status.cloud.google.com/',
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
headers: {},
testModel: 'gemini-pro',
},
Groq: {
statusUrl: 'https://groqstatus.com/',
apiUrl: 'https://api.groq.com/v1/models',
headers: {},
testModel: 'mixtral-8x7b-32768',
},
HuggingFace: {
statusUrl: 'https://status.huggingface.co/',
apiUrl: 'https://api-inference.huggingface.co/models',
headers: {},
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
},
Hyperbolic: {
statusUrl: 'https://status.hyperbolic.ai/',
apiUrl: 'https://api.hyperbolic.ai/v1/models',
headers: {},
testModel: 'hyperbolic-1',
},
Mistral: {
statusUrl: 'https://status.mistral.ai/',
apiUrl: 'https://api.mistral.ai/v1/models',
headers: {},
testModel: 'mistral-tiny',
},
OpenRouter: {
statusUrl: 'https://status.openrouter.ai/',
apiUrl: 'https://openrouter.ai/api/v1/models',
headers: {},
testModel: 'anthropic/claude-3-sonnet',
},
Perplexity: {
statusUrl: 'https://status.perplexity.com/',
apiUrl: 'https://api.perplexity.ai/v1/models',
headers: {},
testModel: 'pplx-7b-chat',
},
Together: {
statusUrl: 'https://status.together.ai/',
apiUrl: 'https://api.together.xyz/v1/models',
headers: {},
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
},
XAI: {
statusUrl: 'https://status.x.ai/',
apiUrl: 'https://api.x.ai/v1/models',
headers: {},
testModel: 'grok-1',
},
};
static getChecker(provider: ProviderName): BaseProviderChecker {
const config = this._providerConfigs[provider];
if (!config) {
throw new Error(`No configuration found for provider: ${provider}`);
}
switch (provider) {
case 'AmazonBedrock':
return new AmazonBedrockStatusChecker(config);
case 'Cohere':
return new CohereStatusChecker(config);
case 'Deepseek':
return new DeepseekStatusChecker(config);
case 'Google':
return new GoogleStatusChecker(config);
case 'Groq':
return new GroqStatusChecker(config);
case 'HuggingFace':
return new HuggingFaceStatusChecker(config);
case 'Hyperbolic':
return new HyperbolicStatusChecker(config);
case 'Mistral':
return new MistralStatusChecker(config);
case 'OpenRouter':
return new OpenRouterStatusChecker(config);
case 'Perplexity':
return new PerplexityStatusChecker(config);
case 'Together':
return new TogetherStatusChecker(config);
case 'XAI':
return new XAIStatusChecker(config);
default:
return new (class extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
const apiStatus = await this.checkEndpoint(this.config.apiUrl);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
})(config);
}
}
static getProviderNames(): ProviderName[] {
return Object.keys(this._providerConfigs) as ProviderName[];
}
static getProviderConfig(provider: ProviderName): ProviderConfig {
const config = this._providerConfigs[provider];
if (!config) {
throw new Error(`Unknown provider: ${provider}`);
}
return config;
}
}

View File

@@ -0,0 +1,76 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class AmazonBedrockStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check AWS health status page
const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
const text = await statusPageResponse.text();
// Check for Bedrock and general AWS status
const hasBedrockIssues =
text.includes('Amazon Bedrock') &&
(text.includes('Service is experiencing elevated error rates') ||
text.includes('Service disruption') ||
text.includes('Degraded Service'));
const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
// Extract incidents
const incidents: string[] = [];
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
for (const match of incidentMatches) {
const [, date, title, impact] = match;
if (title.includes('Bedrock') || title.includes('AWS')) {
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
}
}
let status: StatusCheckResult['status'] = 'operational';
let message = 'All services operational';
if (hasBedrockIssues) {
status = 'degraded';
message = 'Amazon Bedrock service issues reported';
} else if (hasGeneralIssues) {
status = 'degraded';
message = 'AWS experiencing general issues';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents: incidents.slice(0, 5),
};
} catch (error) {
console.error('Error checking Amazon Bedrock status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,80 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class AnthropicStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.anthropic.com/');
const text = await statusPageResponse.text();
// Check for specific Anthropic status indicators
const isOperational = text.includes('All Systems Operational');
const hasDegradedPerformance = text.includes('Degraded Performance');
const hasPartialOutage = text.includes('Partial Outage');
const hasMajorOutage = text.includes('Major Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Only get dated incidents
incidents.push(...incidentLines.slice(0, 5));
}
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (hasMajorOutage) {
status = 'down';
message = 'Major service outage';
} else if (hasPartialOutage) {
status = 'down';
message = 'Partial service outage';
} else if (hasDegradedPerformance) {
status = 'degraded';
message = 'Service experiencing degraded performance';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking Anthropic status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,91 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class CohereStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.cohere.com/');
const text = await statusPageResponse.text();
// Check for specific Cohere status indicators
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Only get dated incidents
incidents.push(...incidentLines.slice(0, 5));
}
// Check specific services
const services = {
api: {
operational: text.includes('API Service') && text.includes('Operational'),
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
outage: text.includes('API Service') && text.includes('Service Outage'),
},
generation: {
operational: text.includes('Generation Service') && text.includes('Operational'),
degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
outage: text.includes('Generation Service') && text.includes('Service Outage'),
},
};
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (services.api.outage || services.generation.outage || hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
const apiEndpoint = 'https://api.cohere.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking Cohere status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
const apiEndpoint = 'https://api.cohere.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,40 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class DeepseekStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
/*
* Check status page - Note: Deepseek doesn't have a public status page yet
* so we'll check their API endpoint directly
*/
const apiEndpoint = 'https://api.deepseek.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
// Check their website as a secondary indicator
const websiteStatus = await this.checkEndpoint('https://deepseek.com');
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
}
return {
status,
message,
incidents: [], // No public incident tracking available yet
};
} catch (error) {
console.error('Error checking Deepseek status:', error);
return {
status: 'degraded',
message: 'Unable to determine service status',
incidents: ['Note: Limited status information available'],
};
}
}
}

View File

@@ -0,0 +1,77 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class GoogleStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.cloud.google.com/');
const text = await statusPageResponse.text();
// Check for Vertex AI and general cloud status
const hasVertexAIIssues =
text.includes('Vertex AI') &&
(text.includes('Incident') ||
text.includes('Disruption') ||
text.includes('Outage') ||
text.includes('degraded'));
const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
// Extract incidents
const incidents: string[] = [];
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
for (const match of incidentMatches) {
const [, date, title, impact] = match;
if (title.includes('Vertex AI') || title.includes('Cloud')) {
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
}
}
let status: StatusCheckResult['status'] = 'operational';
let message = 'All services operational';
if (hasVertexAIIssues) {
status = 'degraded';
message = 'Vertex AI service issues reported';
} else if (hasGeneralIssues) {
status = 'degraded';
message = 'Google Cloud experiencing issues';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents: incidents.slice(0, 5),
};
} catch (error) {
console.error('Error checking Google status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,72 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class GroqStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://groqstatus.com/');
const text = await statusPageResponse.text();
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
for (const match of incidentMatches) {
const [, date, title, status] = match;
incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
}
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
const apiEndpoint = 'https://api.groq.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents: incidents.slice(0, 5),
};
} catch (error) {
console.error('Error checking Groq status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
const apiEndpoint = 'https://api.groq.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,98 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class HuggingFaceStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.huggingface.co/');
const text = await statusPageResponse.text();
// Check for "All services are online" message
const allServicesOnline = text.includes('All services are online');
// Get last update time
const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
// Check individual services and their uptime percentages
const services = {
'Huggingface Hub': {
operational: text.includes('Huggingface Hub') && text.includes('Operational'),
uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
},
'Git Hosting and Serving': {
operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
},
'Inference API': {
operational: text.includes('Inference API') && text.includes('Operational'),
uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
},
'HF Endpoints': {
operational: text.includes('HF Endpoints') && text.includes('Operational'),
uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
},
Spaces: {
operational: text.includes('Spaces') && text.includes('Operational'),
uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
},
};
// Create service status messages with uptime
const serviceMessages = Object.entries(services).map(([name, info]) => {
if (info.uptime) {
return `${name}: ${info.uptime}% uptime`;
}
return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
});
// Determine overall status
let status: StatusCheckResult['status'] = 'operational';
let message = allServicesOnline
? `All services are online (Last updated on ${lastUpdate})`
: 'Checking individual services';
// Only mark as degraded if we explicitly detect issues
const hasIssues = Object.values(services).some((service) => !service.operational);
if (hasIssues) {
status = 'degraded';
message = `Service issues detected (Last updated on ${lastUpdate})`;
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
const apiEndpoint = 'https://api-inference.huggingface.co/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents: serviceMessages,
};
} catch (error) {
console.error('Error checking HuggingFace status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
const apiEndpoint = 'https://api-inference.huggingface.co/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,40 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class HyperbolicStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
/*
* Check API endpoint directly since Hyperbolic is a newer provider
* and may not have a public status page yet
*/
const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
// Check their website as a secondary indicator
const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
}
return {
status,
message,
incidents: [], // No public incident tracking available yet
};
} catch (error) {
console.error('Error checking Hyperbolic status:', error);
return {
status: 'degraded',
message: 'Unable to determine service status',
incidents: ['Note: Limited status information available'],
};
}
}
}

View File

@@ -0,0 +1,76 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class MistralStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.mistral.ai/');
const text = await statusPageResponse.text();
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.includes('No incidents'));
incidents.push(...incidentLines.slice(0, 5));
}
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
const apiEndpoint = 'https://api.mistral.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking Mistral status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
const apiEndpoint = 'https://api.mistral.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,99 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class OpenAIStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.openai.com/');
const text = await statusPageResponse.text();
// Check individual services
const services = {
api: {
operational: text.includes('API ? Operational'),
degraded: text.includes('API ? Degraded Performance'),
outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
},
chat: {
operational: text.includes('ChatGPT ? Operational'),
degraded: text.includes('ChatGPT ? Degraded Performance'),
outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
},
};
// Extract recent incidents
const incidents: string[] = [];
const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
if (incidentMatches) {
const recentIncidents = incidentMatches[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Get only dated incidents
incidents.push(...recentIncidents.slice(0, 5));
}
// Determine overall status
let status: StatusCheckResult['status'] = 'operational';
const messages: string[] = [];
if (services.api.outage || services.chat.outage) {
status = 'down';
if (services.api.outage) {
messages.push('API: Major Outage');
}
if (services.chat.outage) {
messages.push('ChatGPT: Major Outage');
}
} else if (services.api.degraded || services.chat.degraded) {
status = 'degraded';
if (services.api.degraded) {
messages.push('API: Degraded Performance');
}
if (services.chat.degraded) {
messages.push('ChatGPT: Degraded Performance');
}
} else if (services.api.operational) {
messages.push('API: Operational');
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
const apiEndpoint = 'https://api.openai.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message: messages.join(', ') || 'Status unknown',
incidents,
};
} catch (error) {
console.error('Error checking OpenAI status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
const apiEndpoint = 'https://api.openai.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,91 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class OpenRouterStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.openrouter.ai/');
const text = await statusPageResponse.text();
// Check for specific OpenRouter status indicators
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Only get dated incidents
incidents.push(...incidentLines.slice(0, 5));
}
// Check specific services
const services = {
api: {
operational: text.includes('API Service') && text.includes('Operational'),
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
outage: text.includes('API Service') && text.includes('Service Outage'),
},
routing: {
operational: text.includes('Routing Service') && text.includes('Operational'),
degraded: text.includes('Routing Service') && text.includes('Degraded Performance'),
outage: text.includes('Routing Service') && text.includes('Service Outage'),
},
};
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (services.api.outage || services.routing.outage || hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
const apiEndpoint = 'https://openrouter.ai/api/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking OpenRouter status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
const apiEndpoint = 'https://openrouter.ai/api/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,91 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class PerplexityStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.perplexity.ai/');
const text = await statusPageResponse.text();
// Check for specific Perplexity status indicators
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Only get dated incidents
incidents.push(...incidentLines.slice(0, 5));
}
// Check specific services
const services = {
api: {
operational: text.includes('API Service') && text.includes('Operational'),
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
outage: text.includes('API Service') && text.includes('Service Outage'),
},
inference: {
operational: text.includes('Inference Service') && text.includes('Operational'),
degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
outage: text.includes('Inference Service') && text.includes('Service Outage'),
},
};
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (services.api.outage || services.inference.outage || hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
const apiEndpoint = 'https://api.perplexity.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking Perplexity status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
const apiEndpoint = 'https://api.perplexity.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,91 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class TogetherStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
// Check status page
const statusPageResponse = await fetch('https://status.together.ai/');
const text = await statusPageResponse.text();
// Check for specific Together status indicators
const isOperational = text.includes('All Systems Operational');
const hasIncidents = text.includes('Active Incidents');
const hasDegradation = text.includes('Degraded Performance');
const hasOutage = text.includes('Service Outage');
// Extract incidents
const incidents: string[] = [];
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
if (incidentSection) {
const incidentLines = incidentSection[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line.includes('202')); // Only get dated incidents
incidents.push(...incidentLines.slice(0, 5));
}
// Check specific services
const services = {
api: {
operational: text.includes('API Service') && text.includes('Operational'),
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
outage: text.includes('API Service') && text.includes('Service Outage'),
},
inference: {
operational: text.includes('Inference Service') && text.includes('Operational'),
degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
outage: text.includes('Inference Service') && text.includes('Service Outage'),
},
};
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (services.api.outage || services.inference.outage || hasOutage) {
status = 'down';
message = 'Service outage detected';
} else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
status = 'degraded';
message = 'Service experiencing issues';
} else if (!isOperational) {
status = 'degraded';
message = 'Service status unknown';
}
// If status page check fails, fallback to endpoint check
if (!statusPageResponse.ok) {
const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
const apiEndpoint = 'https://api.together.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
return {
status,
message,
incidents,
};
} catch (error) {
console.error('Error checking Together status:', error);
// Fallback to basic endpoint check
const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
const apiEndpoint = 'https://api.together.ai/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
}
}

View File

@@ -0,0 +1,40 @@
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
export class XAIStatusChecker extends BaseProviderChecker {
async checkStatus(): Promise<StatusCheckResult> {
try {
/*
* Check API endpoint directly since XAI is a newer provider
* and may not have a public status page yet
*/
const apiEndpoint = 'https://api.xai.com/v1/models';
const apiStatus = await this.checkEndpoint(apiEndpoint);
// Check their website as a secondary indicator
const websiteStatus = await this.checkEndpoint('https://x.ai');
let status: StatusCheckResult['status'] = 'operational';
let message = 'All systems operational';
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
}
return {
status,
message,
incidents: [], // No public incident tracking available yet
};
} catch (error) {
console.error('Error checking XAI status:', error);
return {
status: 'degraded',
message: 'Unable to determine service status',
incidents: ['Note: Limited status information available'],
};
}
}
}

View File

@@ -0,0 +1,55 @@
import type { IconType } from 'react-icons';
export type ProviderName =
| 'AmazonBedrock'
| 'Cohere'
| 'Deepseek'
| 'Google'
| 'Groq'
| 'HuggingFace'
| 'Hyperbolic'
| 'Mistral'
| 'OpenRouter'
| 'Perplexity'
| 'Together'
| 'XAI';
export type ServiceStatus = {
provider: ProviderName;
status: 'operational' | 'degraded' | 'down';
lastChecked: string;
statusUrl?: string;
icon?: IconType;
message?: string;
responseTime?: number;
incidents?: string[];
};
export interface ProviderConfig {
statusUrl: string;
apiUrl: string;
headers: Record<string, string>;
testModel: string;
}
export type ApiResponse = {
error?: {
message: string;
};
message?: string;
model?: string;
models?: Array<{
id?: string;
name?: string;
}>;
data?: Array<{
id?: string;
name?: string;
}>;
};
export type StatusCheckResult = {
status: 'operational' | 'degraded' | 'down';
message: string;
incidents: string[];
};

View File

@@ -0,0 +1,886 @@
import React, { useEffect, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { TbActivityHeartbeat } from 'react-icons/tb';
import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs';
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
import { BsRobot, BsCloud } from 'react-icons/bs';
import { TbBrain } from 'react-icons/tb';
import { BiChip, BiCodeBlock } from 'react-icons/bi';
import { FaCloud, FaBrain } from 'react-icons/fa';
import type { IconType } from 'react-icons';
import { useSettings } from '~/lib/hooks/useSettings';
import { useToast } from '~/components/ui/use-toast';
// Types
type ProviderName =
| 'AmazonBedrock'
| 'Anthropic'
| 'Cohere'
| 'Deepseek'
| 'Google'
| 'Groq'
| 'HuggingFace'
| 'Mistral'
| 'OpenAI'
| 'OpenRouter'
| 'Perplexity'
| 'Together'
| 'XAI';
type ServiceStatus = {
provider: ProviderName;
status: 'operational' | 'degraded' | 'down';
lastChecked: string;
statusUrl?: string;
icon?: IconType;
message?: string;
responseTime?: number;
incidents?: string[];
};
type ProviderConfig = {
statusUrl: string;
apiUrl: string;
headers: Record<string, string>;
testModel: string;
};
// Types for API responses
type ApiResponse = {
error?: {
message: string;
};
message?: string;
model?: string;
models?: Array<{
id?: string;
name?: string;
}>;
data?: Array<{
id?: string;
name?: string;
}>;
};
// Constants
const PROVIDER_STATUS_URLS: Record<ProviderName, ProviderConfig> = {
OpenAI: {
statusUrl: 'https://status.openai.com/',
apiUrl: 'https://api.openai.com/v1/models',
headers: {
Authorization: 'Bearer $OPENAI_API_KEY',
},
testModel: 'gpt-3.5-turbo',
},
Anthropic: {
statusUrl: 'https://status.anthropic.com/',
apiUrl: 'https://api.anthropic.com/v1/messages',
headers: {
'x-api-key': '$ANTHROPIC_API_KEY',
'anthropic-version': '2024-02-29',
},
testModel: 'claude-3-sonnet-20240229',
},
Cohere: {
statusUrl: 'https://status.cohere.com/',
apiUrl: 'https://api.cohere.ai/v1/models',
headers: {
Authorization: 'Bearer $COHERE_API_KEY',
},
testModel: 'command',
},
Google: {
statusUrl: 'https://status.cloud.google.com/',
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
headers: {
'x-goog-api-key': '$GOOGLE_API_KEY',
},
testModel: 'gemini-pro',
},
HuggingFace: {
statusUrl: 'https://status.huggingface.co/',
apiUrl: 'https://api-inference.huggingface.co/models',
headers: {
Authorization: 'Bearer $HUGGINGFACE_API_KEY',
},
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
},
Mistral: {
statusUrl: 'https://status.mistral.ai/',
apiUrl: 'https://api.mistral.ai/v1/models',
headers: {
Authorization: 'Bearer $MISTRAL_API_KEY',
},
testModel: 'mistral-tiny',
},
Perplexity: {
statusUrl: 'https://status.perplexity.com/',
apiUrl: 'https://api.perplexity.ai/v1/models',
headers: {
Authorization: 'Bearer $PERPLEXITY_API_KEY',
},
testModel: 'pplx-7b-chat',
},
Together: {
statusUrl: 'https://status.together.ai/',
apiUrl: 'https://api.together.xyz/v1/models',
headers: {
Authorization: 'Bearer $TOGETHER_API_KEY',
},
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
},
AmazonBedrock: {
statusUrl: 'https://health.aws.amazon.com/health/status',
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
headers: {
Authorization: 'Bearer $AWS_BEDROCK_CONFIG',
},
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
},
Groq: {
statusUrl: 'https://groqstatus.com/',
apiUrl: 'https://api.groq.com/v1/models',
headers: {
Authorization: 'Bearer $GROQ_API_KEY',
},
testModel: 'mixtral-8x7b-32768',
},
OpenRouter: {
statusUrl: 'https://status.openrouter.ai/',
apiUrl: 'https://openrouter.ai/api/v1/models',
headers: {
Authorization: 'Bearer $OPEN_ROUTER_API_KEY',
},
testModel: 'anthropic/claude-3-sonnet',
},
XAI: {
statusUrl: 'https://status.x.ai/',
apiUrl: 'https://api.x.ai/v1/models',
headers: {
Authorization: 'Bearer $XAI_API_KEY',
},
testModel: 'grok-1',
},
Deepseek: {
statusUrl: 'https://status.deepseek.com/',
apiUrl: 'https://api.deepseek.com/v1/models',
headers: {
Authorization: 'Bearer $DEEPSEEK_API_KEY',
},
testModel: 'deepseek-chat',
},
};
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
AmazonBedrock: SiAmazon,
Anthropic: FaBrain,
Cohere: BiChip,
Google: SiGoogle,
Groq: BsCloud,
HuggingFace: SiHuggingface,
Mistral: TbBrain,
OpenAI: SiOpenai,
OpenRouter: FaCloud,
Perplexity: SiPerplexity,
Together: BsCloud,
XAI: BsRobot,
Deepseek: BiCodeBlock,
};
const ServiceStatusTab = () => {
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [testApiKey, setTestApiKey] = useState<string>('');
const [testProvider, setTestProvider] = useState<ProviderName | ''>('');
const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const settings = useSettings();
const { success, error } = useToast();
// Function to get the API key for a provider from environment variables
const getApiKey = useCallback(
(provider: ProviderName): string | null => {
if (!settings.providers) {
return null;
}
// Map provider names to environment variable names
const envKeyMap: Record<ProviderName, string> = {
OpenAI: 'OPENAI_API_KEY',
Anthropic: 'ANTHROPIC_API_KEY',
Cohere: 'COHERE_API_KEY',
Google: 'GOOGLE_GENERATIVE_AI_API_KEY',
HuggingFace: 'HuggingFace_API_KEY',
Mistral: 'MISTRAL_API_KEY',
Perplexity: 'PERPLEXITY_API_KEY',
Together: 'TOGETHER_API_KEY',
AmazonBedrock: 'AWS_BEDROCK_CONFIG',
Groq: 'GROQ_API_KEY',
OpenRouter: 'OPEN_ROUTER_API_KEY',
XAI: 'XAI_API_KEY',
Deepseek: 'DEEPSEEK_API_KEY',
};
const envKey = envKeyMap[provider];
if (!envKey) {
return null;
}
// Get the API key from environment variables
const apiKey = (import.meta.env[envKey] as string) || null;
// Special handling for providers with base URLs
if (provider === 'Together' && apiKey) {
const baseUrl = import.meta.env.TOGETHER_API_BASE_URL;
if (!baseUrl) {
return null;
}
}
return apiKey;
},
[settings.providers],
);
// Update provider configurations based on available API keys
const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => {
const config = PROVIDER_STATUS_URLS[provider];
if (!config) {
return null;
}
// Handle special cases for providers with base URLs
let updatedConfig = { ...config };
const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL;
if (provider === 'Together' && togetherBaseUrl) {
updatedConfig = {
...config,
apiUrl: `${togetherBaseUrl}/models`,
};
}
return updatedConfig;
}, []);
// Function to check if an API endpoint is accessible with model verification
const checkApiEndpoint = useCallback(
async (
url: string,
headers?: Record<string, string>,
testModel?: string,
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const startTime = performance.now();
// Add common headers
const processedHeaders = {
'Content-Type': 'application/json',
...headers,
};
// First check if the API is accessible
const response = await fetch(url, {
method: 'GET',
headers: processedHeaders,
signal: controller.signal,
});
const endTime = performance.now();
const responseTime = endTime - startTime;
clearTimeout(timeoutId);
// Get response data
const data = (await response.json()) as ApiResponse;
// Special handling for different provider responses
if (!response.ok) {
let errorMessage = `API returned status: ${response.status}`;
// Handle provider-specific error messages
if (data.error?.message) {
errorMessage = data.error.message;
} else if (data.message) {
errorMessage = data.message;
}
return {
ok: false,
status: response.status,
message: errorMessage,
responseTime,
};
}
// Different providers have different model list formats
let models: string[] = [];
if (Array.isArray(data)) {
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
} else if (data.data && Array.isArray(data.data)) {
models = data.data.map((model) => model.id || model.name || '');
} else if (data.models && Array.isArray(data.models)) {
models = data.models.map((model) => model.id || model.name || '');
} else if (data.model) {
// Some providers return single model info
models = [data.model];
}
// For some providers, just having a successful response is enough
if (!testModel || models.length > 0) {
return {
ok: true,
status: response.status,
responseTime,
message: 'API key is valid',
};
}
// If a specific model was requested, verify it exists
if (testModel && !models.includes(testModel)) {
return {
ok: true, // Still mark as ok since API works
status: 'model_not_found',
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
responseTime,
};
}
return {
ok: true,
status: response.status,
message: 'API key is valid',
responseTime,
};
} catch (error) {
console.error(`Error checking API endpoint ${url}:`, error);
return {
ok: false,
status: error instanceof Error ? error.message : 'Unknown error',
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
responseTime: 0,
};
}
},
[getApiKey],
);
// Function to fetch real status from provider status pages
const fetchPublicStatus = useCallback(
async (
provider: ProviderName,
): Promise<{
status: ServiceStatus['status'];
message?: string;
incidents?: string[];
}> => {
try {
// Due to CORS restrictions, we can only check if the endpoints are reachable
const checkEndpoint = async (url: string) => {
try {
const response = await fetch(url, {
mode: 'no-cors',
headers: {
Accept: 'text/html',
},
});
// With no-cors, we can only know if the request succeeded
return response.type === 'opaque' ? 'reachable' : 'unreachable';
} catch (error) {
console.error(`Error checking ${url}:`, error);
return 'unreachable';
}
};
switch (provider) {
case 'HuggingFace': {
const endpointStatus = await checkEndpoint('https://status.huggingface.co/');
// Check API endpoint as fallback
const apiEndpoint = 'https://api-inference.huggingface.co/models';
const apiStatus = await checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
case 'OpenAI': {
const endpointStatus = await checkEndpoint('https://status.openai.com/');
const apiEndpoint = 'https://api.openai.com/v1/models';
const apiStatus = await checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
case 'Google': {
const endpointStatus = await checkEndpoint('https://status.cloud.google.com/');
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
const apiStatus = await checkEndpoint(apiEndpoint);
return {
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
// Similar pattern for other providers...
default:
return {
status: 'operational',
message: 'Basic reachability check only',
incidents: ['Note: Limited status information due to CORS restrictions'],
};
}
} catch (error) {
console.error(`Error fetching status for ${provider}:`, error);
return {
status: 'degraded',
message: 'Unable to fetch status due to CORS restrictions',
incidents: ['Error: Unable to check service status'],
};
}
},
[],
);
// Function to fetch status for a provider with retries
const fetchProviderStatus = useCallback(
async (provider: ProviderName, config: ProviderConfig): Promise<ServiceStatus> => {
const MAX_RETRIES = 2;
const RETRY_DELAY = 2000; // 2 seconds
const attemptCheck = async (attempt: number): Promise<ServiceStatus> => {
try {
// First check the public status page if available
const hasPublicStatus = [
'Anthropic',
'OpenAI',
'Google',
'HuggingFace',
'Mistral',
'Groq',
'Perplexity',
'Together',
].includes(provider);
if (hasPublicStatus) {
const publicStatus = await fetchPublicStatus(provider);
return {
provider,
status: publicStatus.status,
lastChecked: new Date().toISOString(),
statusUrl: config.statusUrl,
icon: PROVIDER_ICONS[provider],
message: publicStatus.message,
incidents: publicStatus.incidents,
};
}
// For other providers, we'll show status but mark API check as separate
const apiKey = getApiKey(provider);
const providerConfig = getProviderConfig(provider);
if (!apiKey || !providerConfig) {
return {
provider,
status: 'operational',
lastChecked: new Date().toISOString(),
statusUrl: config.statusUrl,
icon: PROVIDER_ICONS[provider],
message: !apiKey
? 'Status operational (API key needed for usage)'
: 'Status operational (configuration needed for usage)',
incidents: [],
};
}
// If we have API access, let's verify that too
const { ok, status, message, responseTime } = await checkApiEndpoint(
providerConfig.apiUrl,
providerConfig.headers,
providerConfig.testModel,
);
if (!ok && attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return attemptCheck(attempt + 1);
}
return {
provider,
status: ok ? 'operational' : 'degraded',
lastChecked: new Date().toISOString(),
statusUrl: providerConfig.statusUrl,
icon: PROVIDER_ICONS[provider],
message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`,
responseTime,
incidents: [],
};
} catch (error) {
console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error);
if (attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return attemptCheck(attempt + 1);
}
return {
provider,
status: 'degraded',
lastChecked: new Date().toISOString(),
statusUrl: config.statusUrl,
icon: PROVIDER_ICONS[provider],
message: 'Service operational (Status check error)',
responseTime: 0,
incidents: [],
};
}
};
return attemptCheck(1);
},
[checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus],
);
// Memoize the fetchAllStatuses function
const fetchAllStatuses = useCallback(async () => {
try {
setLoading(true);
const statuses = await Promise.all(
Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) =>
fetchProviderStatus(provider as ProviderName, config),
),
);
setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider)));
setLastRefresh(new Date());
success('Service statuses updated successfully');
} catch (err) {
console.error('Error fetching all statuses:', err);
error('Failed to update service statuses');
} finally {
setLoading(false);
}
}, [fetchProviderStatus, success, error]);
useEffect(() => {
fetchAllStatuses();
// Refresh status every 2 minutes
const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000);
return () => clearInterval(interval);
}, [fetchAllStatuses]);
// Function to test an API key
const testApiKeyForProvider = useCallback(
async (provider: ProviderName, apiKey: string) => {
try {
setTestingStatus('testing');
const config = PROVIDER_STATUS_URLS[provider];
if (!config) {
throw new Error('Provider configuration not found');
}
const headers = { ...config.headers };
// Replace the placeholder API key with the test key
Object.keys(headers).forEach((key) => {
if (headers[key].startsWith('$')) {
headers[key] = headers[key].replace(/\$.*/, apiKey);
}
});
// Special handling for certain providers
switch (provider) {
case 'Anthropic':
headers['anthropic-version'] = '2024-02-29';
break;
case 'OpenAI':
if (!headers.Authorization?.startsWith('Bearer ')) {
headers.Authorization = `Bearer ${apiKey}`;
}
break;
case 'Google': {
// Google uses the API key directly in the URL
const googleUrl = `${config.apiUrl}?key=${apiKey}`;
const result = await checkApiEndpoint(googleUrl, {}, config.testModel);
if (result.ok) {
setTestingStatus('success');
success('API key is valid!');
} else {
setTestingStatus('error');
error(`API key test failed: ${result.message}`);
}
return;
}
}
const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel);
if (ok) {
setTestingStatus('success');
success('API key is valid!');
} else {
setTestingStatus('error');
error(`API key test failed: ${message}`);
}
} catch (err: unknown) {
setTestingStatus('error');
error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error'));
} finally {
// Reset testing status after a delay
setTimeout(() => setTestingStatus('idle'), 3000);
}
},
[checkApiEndpoint, success, error],
);
const getStatusColor = (status: ServiceStatus['status']) => {
switch (status) {
case 'operational':
return 'text-green-500';
case 'degraded':
return 'text-yellow-500';
case 'down':
return 'text-red-500';
default:
return 'text-gray-500';
}
};
const getStatusIcon = (status: ServiceStatus['status']) => {
switch (status) {
case 'operational':
return <BsCheckCircleFill className="w-4 h-4" />;
case 'degraded':
return <BsExclamationCircleFill className="w-4 h-4" />;
case 'down':
return <BsXCircleFill className="w-4 h-4" />;
default:
return <BsXCircleFill className="w-4 h-4" />;
}
};
return (
<div className="space-y-6">
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between gap-2 mt-8 mb-4">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
'text-purple-500',
)}
>
<TbActivityHeartbeat className="w-5 h-5" />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Service Status</h4>
<p className="text-sm text-bolt-elements-textSecondary">
Monitor and test the operational status of cloud LLM providers
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">
Last updated: {lastRefresh.toLocaleTimeString()}
</span>
<button
onClick={() => fetchAllStatuses()}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
'text-bolt-elements-textPrimary',
'transition-all duration-200',
'flex items-center gap-2',
loading ? 'opacity-50 cursor-not-allowed' : '',
)}
disabled={loading}
>
<div className={`i-ph:arrows-clockwise w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>{loading ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* API Key Test Section */}
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Test API Key</h5>
<div className="flex gap-2">
<select
value={testProvider}
onChange={(e) => setTestProvider(e.target.value as ProviderName)}
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
)}
>
<option value="">Select Provider</option>
{Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
<option key={provider} value={provider}>
{provider}
</option>
))}
</select>
<input
type="password"
value={testApiKey}
onChange={(e) => setTestApiKey(e.target.value)}
placeholder="Enter API key to test"
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
)}
/>
<button
onClick={() =>
testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
}
disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
className={classNames(
'px-4 py-1.5 rounded-lg text-sm',
'bg-purple-500 hover:bg-purple-600',
'text-white',
'transition-all duration-200',
'flex items-center gap-2',
!testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{testingStatus === 'testing' ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
<span>Testing...</span>
</>
) : (
<>
<div className="i-ph:key w-4 h-4" />
<span>Test Key</span>
</>
)}
</button>
</div>
</div>
{/* Status Grid */}
{loading && serviceStatuses.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">Loading service statuses...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{serviceStatuses.map((service, index) => (
<motion.div
key={service.provider}
className={classNames(
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden rounded-lg',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
<div
className={classNames('block p-4', service.statusUrl ? 'cursor-pointer' : '')}
onClick={() => service.statusUrl && window.open(service.statusUrl, '_blank')}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{service.icon && (
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
getStatusColor(service.status),
)}
>
{React.createElement(service.icon, {
className: 'w-5 h-5',
})}
</div>
)}
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{service.provider}</h4>
<div className="space-y-1">
<p className="text-xs text-bolt-elements-textSecondary">
Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
</p>
{service.responseTime && (
<p className="text-xs text-bolt-elements-textTertiary">
Response time: {Math.round(service.responseTime)}ms
</p>
)}
{service.message && (
<p className="text-xs text-bolt-elements-textTertiary">{service.message}</p>
)}
</div>
</div>
</div>
<div className={classNames('flex items-center gap-2', getStatusColor(service.status))}>
<span className="text-sm capitalize">{service.status}</span>
{getStatusIcon(service.status)}
</div>
</div>
{service.incidents && service.incidents.length > 0 && (
<div className="mt-2 border-t border-bolt-elements-borderColor pt-2">
<p className="text-xs font-medium text-bolt-elements-textSecondary mb-1">Recent Incidents:</p>
<ul className="text-xs text-bolt-elements-textTertiary space-y-1">
{service.incidents.map((incident, i) => (
<li key={i}>{incident}</li>
))}
</ul>
</div>
)}
</div>
</motion.div>
))}
</div>
)}
</motion.div>
</div>
);
};
// Add tab metadata
ServiceStatusTab.tabMetadata = {
icon: 'i-ph:activity-bold',
description: 'Monitor and test LLM provider service status',
category: 'services',
};
export default ServiceStatusTab;

View File

@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Switch } from '~/components/ui/Switch';
import type { UserProfile } from '~/components/@settings/core/types';
import { isMac } from '~/utils/os';
// Helper to get modifier key symbols/text
const getModifierSymbol = (modifier: string): string => {
switch (modifier) {
case 'meta':
return isMac ? '⌘' : 'Win';
case 'alt':
return isMac ? '⌥' : 'Alt';
case 'shift':
return '⇧';
default:
return modifier;
}
};
export default function SettingsTab() {
const [currentTimezone, setCurrentTimezone] = useState('');
const [settings, setSettings] = useState<UserProfile>(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved
? JSON.parse(saved)
: {
notifications: true,
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
});
useEffect(() => {
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
// Save settings automatically when they change
useEffect(() => {
try {
// Get existing profile data
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
// Merge with new settings
const updatedProfile = {
...existingProfile,
notifications: settings.notifications,
language: settings.language,
timezone: settings.timezone,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
toast.success('Settings updated');
} catch (error) {
console.error('Error saving settings:', error);
toast.error('Failed to update settings');
}
}, [settings]);
return (
<div className="space-y-4">
{/* Language & Notifications */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Preferences</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Language</label>
</div>
<select
value={settings.language}
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
<option value="ko"></option>
</select>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textSecondary">
{settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
</span>
<Switch
checked={settings.notifications}
onCheckedChange={(checked) => {
// Update local state
setSettings((prev) => ({ ...prev, notifications: checked }));
// Update localStorage immediately
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
const updatedProfile = {
...existingProfile,
notifications: checked,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
// Dispatch storage event for other components
window.dispatchEvent(
new StorageEvent('storage', {
key: 'bolt_user_profile',
newValue: JSON.stringify(updatedProfile),
}),
);
toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
}}
/>
</div>
</div>
</motion.div>
{/* Timezone */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
</div>
<select
value={settings.timezone}
onChange={(e) => setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
>
<option value={currentTimezone}>{currentTimezone}</option>
</select>
</div>
</motion.div>
{/* Simplified Keyboard Shortcuts */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:keyboard-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Keyboard Shortcuts</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A]">
<div className="flex flex-col">
<span className="text-sm text-bolt-elements-textPrimary">Toggle Theme</span>
<span className="text-xs text-bolt-elements-textSecondary">Switch between light and dark mode</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
{getModifierSymbol('meta')}
</kbd>
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
{getModifierSymbol('alt')}
</kbd>
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
{getModifierSymbol('shift')}
</kbd>
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
D
</kbd>
</div>
</div>
</div>
</motion.div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,628 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useSettings } from '~/lib/hooks/useSettings';
import { logStore } from '~/lib/stores/logs';
import { toast } from 'react-toastify';
import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog';
import { classNames } from '~/utils/classNames';
import { Markdown } from '~/components/chat/Markdown';
interface UpdateProgress {
stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
message: string;
progress?: number;
error?: string;
details?: {
changedFiles?: string[];
additions?: number;
deletions?: number;
commitMessages?: string[];
totalSize?: string;
currentCommit?: string;
remoteCommit?: string;
updateReady?: boolean;
changelog?: string;
compareUrl?: string;
};
}
interface UpdateSettings {
autoUpdate: boolean;
notifyInApp: boolean;
checkInterval: number;
}
const ProgressBar = ({ progress }: { progress: number }) => (
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
className="h-full bg-blue-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
);
const UpdateProgressDisplay = ({ progress }: { progress: UpdateProgress }) => (
<div className="mt-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{progress.message}</span>
<span className="text-sm text-gray-500">{progress.progress}%</span>
</div>
<ProgressBar progress={progress.progress || 0} />
{progress.details && (
<div className="mt-2 text-sm text-gray-600">
{progress.details.changedFiles && progress.details.changedFiles.length > 0 && (
<div className="mt-4">
<div className="font-medium mb-2">Changed Files:</div>
<div className="space-y-2">
{/* Group files by type */}
{['Modified', 'Added', 'Deleted'].map((type) => {
const filesOfType = progress.details?.changedFiles?.filter((file) => file.startsWith(type)) || [];
if (filesOfType.length === 0) {
return null;
}
return (
<div key={type} className="space-y-1">
<div
className={classNames('text-sm font-medium', {
'text-blue-500': type === 'Modified',
'text-green-500': type === 'Added',
'text-red-500': type === 'Deleted',
})}
>
{type} ({filesOfType.length})
</div>
<div className="pl-4 space-y-1">
{filesOfType.map((file, index) => {
const fileName = file.split(': ')[1];
return (
<div key={index} className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div
className={classNames('w-4 h-4', {
'i-ph:pencil-simple': type === 'Modified',
'i-ph:plus': type === 'Added',
'i-ph:trash': type === 'Deleted',
'text-blue-500': type === 'Modified',
'text-green-500': type === 'Added',
'text-red-500': type === 'Deleted',
})}
/>
<span className="font-mono text-xs">{fileName}</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
{progress.details.totalSize && <div className="mt-1">Total size: {progress.details.totalSize}</div>}
{progress.details.additions !== undefined && progress.details.deletions !== undefined && (
<div className="mt-1">
Changes: <span className="text-green-600">+{progress.details.additions}</span>{' '}
<span className="text-red-600">-{progress.details.deletions}</span>
</div>
)}
{progress.details.currentCommit && progress.details.remoteCommit && (
<div className="mt-1">
Updating from {progress.details.currentCommit} to {progress.details.remoteCommit}
</div>
)}
</div>
)}
</div>
);
const UpdateTab = () => {
const { isLatestBranch } = useSettings();
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [updateSettings, setUpdateSettings] = useState<UpdateSettings>(() => {
const stored = localStorage.getItem('update_settings');
return stored
? JSON.parse(stored)
: {
autoUpdate: false,
notifyInApp: true,
checkInterval: 24,
};
});
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [updateProgress, setUpdateProgress] = useState<UpdateProgress | null>(null);
useEffect(() => {
localStorage.setItem('update_settings', JSON.stringify(updateSettings));
}, [updateSettings]);
const checkForUpdates = async () => {
console.log('Starting update check...');
setIsChecking(true);
setError(null);
setUpdateProgress(null);
try {
const branchToCheck = isLatestBranch ? 'main' : 'stable';
// Start the update check with streaming progress
const response = await fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: branchToCheck,
autoUpdate: updateSettings.autoUpdate,
}),
});
if (!response.ok) {
throw new Error(`Update check failed: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response stream available');
}
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Convert the chunk to text and parse the JSON
const chunk = new TextDecoder().decode(value);
const lines = chunk.split('\n').filter(Boolean);
for (const line of lines) {
try {
const progress = JSON.parse(line) as UpdateProgress;
setUpdateProgress(progress);
if (progress.error) {
setError(progress.error);
}
// If we're done, update the UI accordingly
if (progress.stage === 'complete') {
setIsChecking(false);
if (!progress.error) {
// Update check completed
toast.success('Update check completed');
// Show update dialog only if there are changes and auto-update is disabled
if (progress.details?.changedFiles?.length && progress.details.updateReady) {
setShowUpdateDialog(true);
}
}
}
} catch (e) {
console.error('Error parsing progress update:', e);
}
}
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error occurred');
logStore.logWarning('Update Check Failed', {
type: 'update',
message: error instanceof Error ? error.message : 'Unknown error occurred',
});
} finally {
setIsChecking(false);
}
};
const handleUpdate = async () => {
setShowUpdateDialog(false);
try {
const branchToCheck = isLatestBranch ? 'main' : 'stable';
// Start the update with autoUpdate set to true to force the update
const response = await fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: branchToCheck,
autoUpdate: true,
}),
});
if (!response.ok) {
throw new Error(`Update failed: ${response.statusText}`);
}
// Handle the update progress stream
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response stream available');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = new TextDecoder().decode(value);
const lines = chunk.split('\n').filter(Boolean);
for (const line of lines) {
try {
const progress = JSON.parse(line) as UpdateProgress;
setUpdateProgress(progress);
if (progress.error) {
setError(progress.error);
toast.error('Update failed');
}
if (progress.stage === 'complete' && !progress.error) {
toast.success('Update completed successfully');
}
} catch (e) {
console.error('Error parsing update progress:', e);
}
}
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error occurred');
toast.error('Update failed');
}
};
return (
<div className="flex flex-col gap-6">
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="i-ph:arrow-circle-up text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Updates</h3>
<p className="text-sm text-bolt-elements-textSecondary">Check for and manage application updates</p>
</div>
</motion.div>
{/* Update Settings Card */}
<motion.div
className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<div className="flex items-center gap-3 mb-6">
<div className="i-ph:gear text-purple-500 w-5 h-5" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Settings</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-bolt-elements-textPrimary">Automatic Updates</span>
<p className="text-xs text-bolt-elements-textSecondary">
Automatically check and apply updates when available
</p>
</div>
<button
onClick={() => setUpdateSettings((prev) => ({ ...prev, autoUpdate: !prev.autoUpdate }))}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
updateSettings.autoUpdate ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
)}
>
<span
className={classNames(
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
updateSettings.autoUpdate ? 'translate-x-6' : 'translate-x-1',
)}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-bolt-elements-textPrimary">In-App Notifications</span>
<p className="text-xs text-bolt-elements-textSecondary">Show notifications when updates are available</p>
</div>
<button
onClick={() => setUpdateSettings((prev) => ({ ...prev, notifyInApp: !prev.notifyInApp }))}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
updateSettings.notifyInApp ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
)}
>
<span
className={classNames(
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
updateSettings.notifyInApp ? 'translate-x-6' : 'translate-x-1',
)}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-bolt-elements-textPrimary">Check Interval</span>
<p className="text-xs text-bolt-elements-textSecondary">How often to check for updates</p>
</div>
<select
value={updateSettings.checkInterval}
onChange={(e) => setUpdateSettings((prev) => ({ ...prev, checkInterval: Number(e.target.value) }))}
className={classNames(
'px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'hover:bg-[#E5E5E5] dark:hover:bg-[#2A2A2A]',
'transition-colors duration-200',
)}
>
<option value="6">6 hours</option>
<option value="12">12 hours</option>
<option value="24">24 hours</option>
<option value="48">48 hours</option>
</select>
</div>
</div>
</motion.div>
{/* Update Status Card */}
<motion.div
className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="i-ph:arrows-clockwise text-purple-500 w-5 h-5" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Status</h3>
</div>
<div className="flex items-center gap-2">
{updateProgress?.details?.updateReady && !updateSettings.autoUpdate && (
<button
onClick={handleUpdate}
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'transition-colors duration-200',
)}
>
<div className="i-ph:arrow-circle-up w-4 h-4" />
Update Now
</button>
)}
<button
onClick={() => {
setError(null);
checkForUpdates();
}}
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'text-bolt-elements-textPrimary',
'transition-colors duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
disabled={isChecking}
>
{isChecking ? (
<div className="flex items-center gap-2">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="i-ph:arrows-clockwise w-4 h-4"
/>
Checking...
</div>
) : (
<>
<div className="i-ph:arrows-clockwise w-4 h-4" />
Check for Updates
</>
)}
</button>
</div>
</div>
{/* Show progress information */}
{updateProgress && <UpdateProgressDisplay progress={updateProgress} />}
{error && <div className="mt-4 p-4 bg-red-100 text-red-700 rounded">{error}</div>}
{/* Show update source information */}
{updateProgress?.details?.currentCommit && updateProgress?.details?.remoteCommit && (
<div className="mt-4 text-sm text-bolt-elements-textSecondary">
<div className="flex items-center justify-between">
<div>
<p>
Updates are fetched from: <span className="font-mono">stackblitz-labs/bolt.diy</span> (
{isLatestBranch ? 'main' : 'stable'} branch)
</p>
<p className="mt-1">
Current version: <span className="font-mono">{updateProgress.details.currentCommit}</span>
<span className="mx-2"></span>
Latest version: <span className="font-mono">{updateProgress.details.remoteCommit}</span>
</p>
</div>
{updateProgress?.details?.compareUrl && (
<a
href={updateProgress.details.compareUrl}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'text-bolt-elements-textPrimary',
'transition-colors duration-200',
'w-fit',
)}
>
<div className="i-ph:github-logo w-4 h-4" />
View Changes on GitHub
</a>
)}
</div>
{updateProgress?.details?.additions !== undefined && updateProgress?.details?.deletions !== undefined && (
<div className="mt-2 flex items-center gap-2">
<div className="i-ph:git-diff text-purple-500 w-4 h-4" />
Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
<span className="text-red-600">-{updateProgress.details.deletions}</span>
</div>
)}
</div>
)}
{/* Add this before the changed files section */}
{updateProgress?.details?.changelog && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:scroll text-purple-500 w-5 h-5" />
<p className="font-medium">Changelog</p>
</div>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[300px]">
<div className="prose dark:prose-invert prose-sm max-w-none">
<Markdown>{updateProgress.details.changelog}</Markdown>
</div>
</div>
</div>
)}
{/* Add this in the update status card, after the commit info */}
{updateProgress?.details?.compareUrl && (
<div className="mt-4">
<a
href={updateProgress.details.compareUrl}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'text-bolt-elements-textPrimary',
'transition-colors duration-200',
'w-fit',
)}
>
<div className="i-ph:github-logo w-4 h-4" />
View Changes on GitHub
</a>
</div>
)}
{updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
<div className="mb-6">
<p className="font-medium mb-2">Changes in this Update:</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[400px]">
<div className="prose dark:prose-invert prose-sm max-w-none">
{updateProgress.details.commitMessages.map((section, index) => (
<Markdown key={index}>{section}</Markdown>
))}
</div>
</div>
</div>
)}
</motion.div>
{/* Update dialog */}
<DialogRoot open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
<Dialog>
<DialogTitle>Update Available</DialogTitle>
<DialogDescription>
<div className="mt-4">
<p className="text-sm text-bolt-elements-textSecondary mb-4">
A new version is available from <span className="font-mono">stackblitz-labs/bolt.diy</span> (
{isLatestBranch ? 'main' : 'stable'} branch)
</p>
{updateProgress?.details?.compareUrl && (
<div className="mb-6">
<a
href={updateProgress.details.compareUrl}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'text-bolt-elements-textPrimary',
'transition-colors duration-200',
'w-fit',
)}
>
<div className="i-ph:github-logo w-4 h-4" />
View Changes on GitHub
</a>
</div>
)}
{updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
<div className="mb-6">
<p className="font-medium mb-2">Commit Messages:</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 space-y-2">
{updateProgress.details.commitMessages.map((msg, index) => (
<div key={index} className="text-sm text-bolt-elements-textSecondary flex items-start gap-2">
<div className="i-ph:git-commit text-purple-500 w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{msg}</span>
</div>
))}
</div>
</div>
)}
{updateProgress?.details?.totalSize && (
<div className="flex items-center gap-4 text-sm text-bolt-elements-textSecondary">
<div className="flex items-center gap-2">
<div className="i-ph:file text-purple-500 w-4 h-4" />
Total size: {updateProgress.details.totalSize}
</div>
{updateProgress?.details?.additions !== undefined &&
updateProgress?.details?.deletions !== undefined && (
<div className="flex items-center gap-2">
<div className="i-ph:git-diff text-purple-500 w-4 h-4" />
Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
<span className="text-red-600">-{updateProgress.details.deletions}</span>
</div>
)}
</div>
)}
</div>
</DialogDescription>
<div className="flex justify-end gap-2 mt-6">
<DialogButton type="secondary" onClick={() => setShowUpdateDialog(false)}>
Cancel
</DialogButton>
<DialogButton type="primary" onClick={handleUpdate}>
Update Now
</DialogButton>
</div>
</Dialog>
</DialogRoot>
</div>
);
};
export default UpdateTab;

View File

@@ -0,0 +1,41 @@
import type { Variants } from 'framer-motion';
export const fadeIn: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const slideIn: Variants = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
export const scaleIn: Variants = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
};
export const tabAnimation: Variants = {
initial: { opacity: 0, scale: 0.8, y: 20 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.8, y: -20 },
};
export const overlayAnimation: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const modalAnimation: Variants = {
initial: { opacity: 0, scale: 0.95, y: 20 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: 20 },
};
export const transition = {
duration: 0.2,
};

View File

@@ -0,0 +1,89 @@
import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
export const getVisibleTabs = (
tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
isDeveloperMode: boolean,
notificationsEnabled: boolean,
): TabVisibilityConfig[] => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, using defaults');
return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
}
// In developer mode, show ALL tabs without restrictions
if (isDeveloperMode) {
// Combine all unique tabs from both user and developer configurations
const allTabs = new Set([
...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
...tabConfiguration.userTabs.map((tab) => tab.id),
...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
'task-manager' as TabType, // Always include task-manager in developer mode
]);
// Create a complete tab list with all tabs visible
const devTabs = Array.from(allTabs).map((tabId) => {
// Try to find existing configuration for this tab
const existingTab =
tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
return {
id: tabId as TabType,
visible: true,
window: 'developer' as const,
order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
} as TabVisibilityConfig;
});
return devTabs.sort((a, b) => a.order - b.order);
}
// In user mode, only show visible user tabs
return tabConfiguration.userTabs
.filter((tab) => {
if (!tab || typeof tab.id !== 'string') {
console.warn('Invalid tab entry:', tab);
return false;
}
// Hide notifications tab if notifications are disabled
if (tab.id === 'notifications' && !notificationsEnabled) {
return false;
}
// Always show task-manager in user mode if it's configured as visible
if (tab.id === 'task-manager') {
return tab.visible;
}
// Only show tabs that are explicitly visible and assigned to the user window
return tab.visible && tab.window === 'user';
})
.sort((a, b) => a.order - b.order);
};
export const reorderTabs = (
tabs: TabVisibilityConfig[],
startIndex: number,
endIndex: number,
): TabVisibilityConfig[] => {
const result = Array.from(tabs);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
// Update order property
return result.map((tab, index) => ({
...tab,
order: index,
}));
};
export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
return DEFAULT_TAB_CONFIG.map((tab) => ({
...tab,
visible: isDeveloperMode ? true : tab.window === 'user',
window: isDeveloperMode ? 'developer' : tab.window,
})) as TabVisibilityConfig[];
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { IconButton } from '~/components/ui/IconButton'; import { IconButton } from '~/components/ui/IconButton';
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
@@ -11,11 +11,14 @@ interface APIKeyManagerProps {
labelForGetApiKey?: string; labelForGetApiKey?: string;
} }
// cache which stores whether the provider's API key is set via environment variable
const providerEnvKeyStatusCache: Record<string, boolean> = {};
const apiKeyMemoizeCache: { [k: string]: Record<string, string> } = {}; const apiKeyMemoizeCache: { [k: string]: Record<string, string> } = {};
export function getApiKeysFromCookies() { export function getApiKeysFromCookies() {
const storedApiKeys = Cookies.get('apiKeys'); const storedApiKeys = Cookies.get('apiKeys');
let parsedKeys = {}; let parsedKeys: Record<string, string> = {};
if (storedApiKeys) { if (storedApiKeys) {
parsedKeys = apiKeyMemoizeCache[storedApiKeys]; parsedKeys = apiKeyMemoizeCache[storedApiKeys];
@@ -32,54 +35,135 @@ export function getApiKeysFromCookies() {
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => { export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [tempKey, setTempKey] = useState(apiKey); const [tempKey, setTempKey] = useState(apiKey);
const [isEnvKeySet, setIsEnvKeySet] = useState(false);
// Reset states and load saved key when provider changes
useEffect(() => {
// Load saved API key from cookies for this provider
const savedKeys = getApiKeysFromCookies();
const savedKey = savedKeys[provider.name] || '';
setTempKey(savedKey);
setApiKey(savedKey);
setIsEditing(false);
}, [provider.name]);
const checkEnvApiKey = useCallback(async () => {
// Check cache first
if (providerEnvKeyStatusCache[provider.name] !== undefined) {
setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]);
return;
}
try {
const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`);
const data = await response.json();
const isSet = (data as { isSet: boolean }).isSet;
// Cache the result
providerEnvKeyStatusCache[provider.name] = isSet;
setIsEnvKeySet(isSet);
} catch (error) {
console.error('Failed to check environment API key:', error);
setIsEnvKeySet(false);
}
}, [provider.name]);
useEffect(() => {
checkEnvApiKey();
}, [checkEnvApiKey]);
const handleSave = () => { const handleSave = () => {
// Save to parent state
setApiKey(tempKey); setApiKey(tempKey);
// Save to cookies
const currentKeys = getApiKeysFromCookies();
const newKeys = { ...currentKeys, [provider.name]: tempKey };
Cookies.set('apiKeys', JSON.stringify(newKeys));
setIsEditing(false); setIsEditing(false);
}; };
return ( return (
<div className="flex items-start sm:items-center mt-2 mb-2 flex-col sm:flex-row"> <div className="flex items-center justify-between py-3 px-1">
<div> <div className="flex items-center gap-2 flex-1">
<span className="text-sm text-bolt-elements-textSecondary">{provider?.name} API Key:</span> <div className="flex items-center gap-2">
{!isEditing && ( <span className="text-sm font-medium text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
<div className="flex items-center mb-4"> {!isEditing && (
<span className="flex-1 text-xs text-bolt-elements-textPrimary mr-2"> <div className="flex items-center gap-2">
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'} {apiKey ? (
</span> <>
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key"> <div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
<div className="i-ph:pencil-simple" /> <span className="text-xs text-green-500">Set via UI</span>
</IconButton> </>
</div> ) : isEnvKeySet ? (
)} <>
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
<span className="text-xs text-green-500">Set via environment variable</span>
</>
) : (
<>
<div className="i-ph:x-circle-fill text-red-500 w-4 h-4" />
<span className="text-xs text-red-500">Not Set (Please set via UI or ENV_VAR)</span>
</>
)}
</div>
)}
</div>
</div> </div>
{isEditing ? ( <div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-3 mt-2"> {isEditing ? (
<input <div className="flex items-center gap-2">
type="password" <input
value={tempKey} type="password"
placeholder="Your API Key" value={tempKey}
onChange={(e) => setTempKey(e.target.value)} placeholder="Enter API Key"
className="flex-1 px-2 py-1 text-xs lg:text-sm rounded border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus" onChange={(e) => setTempKey(e.target.value)}
/> className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor
<IconButton onClick={handleSave} title="Save API Key"> bg-bolt-elements-prompt-background text-bolt-elements-textPrimary
<div className="i-ph:check" /> focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
</IconButton> />
<IconButton onClick={() => setIsEditing(false)} title="Cancel"> <IconButton
<div className="i-ph:x" /> onClick={handleSave}
</IconButton> title="Save API Key"
</div> className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
) : ( >
<> <div className="i-ph:check w-4 h-4" />
{provider?.getApiKeyLink && (
<IconButton className="ml-auto" onClick={() => window.open(provider?.getApiKeyLink)} title="Edit API Key">
<span className="mr-2 text-xs lg:text-sm">{provider?.labelForGetApiKey || 'Get API Key'}</span>
<div className={provider?.icon || 'i-ph:key'} />
</IconButton> </IconButton>
)} <IconButton
</> onClick={() => setIsEditing(false)}
)} title="Cancel"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<div className="i-ph:x w-4 h-4" />
</IconButton>
</div>
) : (
<>
{
<IconButton
onClick={() => setIsEditing(true)}
title="Edit API Key"
className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
>
<div className="i-ph:pencil-simple w-4 h-4" />
</IconButton>
}
{provider?.getApiKeyLink && !apiKey && (
<IconButton
onClick={() => window.open(provider?.getApiKeyLink)}
title="Get API Key"
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2"
>
<span className="text-xs whitespace-nowrap">{provider?.labelForGetApiKey || 'Get API Key'}</span>
<div className={`${provider?.icon || 'i-ph:key'} w-4 h-4`} />
</IconButton>
)}
</>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -1,16 +1,55 @@
import { memo } from 'react'; import { memo } from 'react';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import type { JSONValue } from 'ai'; import type { JSONValue } from 'ai';
import Popover from '~/components/ui/Popover';
import { workbenchStore } from '~/lib/stores/workbench';
import { WORK_DIR } from '~/utils/constants';
interface AssistantMessageProps { interface AssistantMessageProps {
content: string; content: string;
annotations?: JSONValue[]; annotations?: JSONValue[];
} }
function openArtifactInWorkbench(filePath: string) {
filePath = normalizedFilePath(filePath);
if (workbenchStore.currentView.get() !== 'code') {
workbenchStore.currentView.set('code');
}
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
}
function normalizedFilePath(path: string) {
let normalizedPath = path;
if (normalizedPath.startsWith(WORK_DIR)) {
normalizedPath = path.replace(WORK_DIR, '');
}
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.slice(1);
}
return normalizedPath;
}
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => { export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
const filteredAnnotations = (annotations?.filter( const filteredAnnotations = (annotations?.filter(
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'), (annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
) || []) as { type: string; value: any }[]; ) || []) as { type: string; value: any } & { [key: string]: any }[];
let chatSummary: string | undefined = undefined;
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
}
let codeContext: string[] | undefined = undefined;
if (filteredAnnotations.find((annotation) => annotation.type === 'codeContext')) {
codeContext = filteredAnnotations.find((annotation) => annotation.type === 'codeContext')?.files;
}
const usage: { const usage: {
completionTokens: number; completionTokens: number;
@@ -20,11 +59,54 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
return ( return (
<div className="overflow-hidden w-full"> <div className="overflow-hidden w-full">
{usage && ( <>
<div className="text-sm text-bolt-elements-textSecondary mb-2"> <div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens}) {(codeContext || chatSummary) && (
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
{chatSummary && (
<div className="max-w-chat">
<div className="summary max-h-96 flex flex-col">
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
<Markdown>{chatSummary}</Markdown>
</div>
</div>
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</>
);
})}
</div>
</div>
)}
</div>
)}
<div className="context"></div>
</Popover>
)}
{usage && (
<div>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
</div> </div>
)} </>
<Markdown html>{content}</Markdown> <Markdown html>{content}</Markdown>
</div> </div>
); );

View File

@@ -2,14 +2,14 @@
* @ts-nocheck * @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation. * Preventing TS checks with files presented in the video for a better presentation.
*/ */
import type { Message } from 'ai'; import type { JSONValue, Message } from 'ai';
import React, { type RefCallback, useCallback, useEffect, useState } from 'react'; import React, { type RefCallback, useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only'; import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client'; import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton'; import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client'; import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants'; import { PROVIDER_LIST } from '~/utils/constants';
import { Messages } from './Messages.client'; import { Messages } from './Messages.client';
import { SendButton } from './SendButton.client'; import { SendButton } from './SendButton.client';
import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager'; import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager';
@@ -25,13 +25,16 @@ import GitCloneButton from './GitCloneButton';
import FilePreview from './FilePreview'; import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector'; import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import type { IProviderSetting, ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager'; import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import StarterTemplates from './StarterTemplates'; import StarterTemplates from './StarterTemplates';
import type { ActionAlert } from '~/types/actions'; import type { ActionAlert } from '~/types/actions';
import ChatAlert from './ChatAlert'; import ChatAlert from './ChatAlert';
import { LLMManager } from '~/lib/modules/llm/manager'; import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
const TEXTAREA_MIN_HEIGHT = 76; const TEXTAREA_MIN_HEIGHT = 76;
@@ -64,6 +67,7 @@ interface BaseChatProps {
setImageDataList?: (dataList: string[]) => void; setImageDataList?: (dataList: string[]) => void;
actionAlert?: ActionAlert; actionAlert?: ActionAlert;
clearAlert?: () => void; clearAlert?: () => void;
data?: JSONValue[] | undefined;
} }
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>( export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -97,40 +101,27 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages, messages,
actionAlert, actionAlert,
clearAlert, clearAlert,
data,
}, },
ref, ref,
) => { ) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies()); const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies());
const [modelList, setModelList] = useState(MODEL_LIST); const [modelList, setModelList] = useState<ModelInfo[]>([]);
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false); const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
const [isListening, setIsListening] = useState(false); const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null); const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState(''); const [transcript, setTranscript] = useState('');
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all'); const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
const getProviderSettings = useCallback(() => { useEffect(() => {
let providerSettings: Record<string, IProviderSetting> | undefined = undefined; if (data) {
const progressList = data.filter(
try { (x) => typeof x === 'object' && (x as any).type === 'progress',
const savedProviderSettings = Cookies.get('providers'); ) as ProgressAnnotation[];
setProgressAnnotations(progressList);
if (savedProviderSettings) {
const parsedProviderSettings = JSON.parse(savedProviderSettings);
if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) {
providerSettings = parsedProviderSettings;
}
}
} catch (error) {
console.error('Error loading Provider Settings from cookies:', error);
// Clear invalid cookie data
Cookies.remove('providers');
} }
}, [data]);
return providerSettings;
}, []);
useEffect(() => { useEffect(() => {
console.log(transcript); console.log(transcript);
}, [transcript]); }, [transcript]);
@@ -169,7 +160,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const providerSettings = getProviderSettings();
let parsedApiKeys: Record<string, string> | undefined = {}; let parsedApiKeys: Record<string, string> | undefined = {};
try { try {
@@ -177,53 +167,48 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
setApiKeys(parsedApiKeys); setApiKeys(parsedApiKeys);
} catch (error) { } catch (error) {
console.error('Error loading API keys from cookies:', error); console.error('Error loading API keys from cookies:', error);
// Clear invalid cookie data
Cookies.remove('apiKeys'); Cookies.remove('apiKeys');
} }
setIsModelLoading('all'); setIsModelLoading('all');
initializeModelList({ apiKeys: parsedApiKeys, providerSettings }) fetch('/api/models')
.then((modelList) => { .then((response) => response.json())
// console.log('Model List: ', modelList); .then((data) => {
setModelList(modelList); const typedData = data as { modelList: ModelInfo[] };
setModelList(typedData.modelList);
}) })
.catch((error) => { .catch((error) => {
console.error('Error initializing model list:', error); console.error('Error fetching model list:', error);
}) })
.finally(() => { .finally(() => {
setIsModelLoading(undefined); setIsModelLoading(undefined);
}); });
} }
}, [providerList]); }, [providerList, provider]);
const onApiKeysChange = async (providerName: string, apiKey: string) => { const onApiKeysChange = async (providerName: string, apiKey: string) => {
const newApiKeys = { ...apiKeys, [providerName]: apiKey }; const newApiKeys = { ...apiKeys, [providerName]: apiKey };
setApiKeys(newApiKeys); setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys)); Cookies.set('apiKeys', JSON.stringify(newApiKeys));
const provider = LLMManager.getInstance(import.meta.env || process.env || {}).getProvider(providerName); setIsModelLoading(providerName);
if (provider && provider.getDynamicModels) { let providerModels: ModelInfo[] = [];
setIsModelLoading(providerName);
try { try {
const providerSettings = getProviderSettings(); const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
const staticModels = provider.staticModels; const data = await response.json();
const dynamicModels = await provider.getDynamicModels( providerModels = (data as { modelList: ModelInfo[] }).modelList;
newApiKeys, } catch (error) {
providerSettings, console.error('Error loading dynamic models for:', providerName, error);
import.meta.env || process.env || {},
);
setModelList((preModels) => {
const filteredOutPreModels = preModels.filter((x) => x.provider !== providerName);
return [...filteredOutPreModels, ...staticModels, ...dynamicModels];
});
} catch (error) {
console.error('Error loading dynamic models:', error);
}
setIsModelLoading(undefined);
} }
// Only update models for the specific provider
setModelList((prevModels) => {
const otherModels = prevModels.filter((model) => model.provider !== providerName);
return [...otherModels, ...providerModels];
});
setIsModelLoading(undefined);
}; };
const startListening = () => { const startListening = () => {
@@ -319,7 +304,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
data-chat-visible={showChat} data-chat-visible={showChat}
> >
<ClientOnly>{() => <Menu />}</ClientOnly> <ClientOnly>{() => <Menu />}</ClientOnly>
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full"> <div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}> <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && ( {!chatStarted && (
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0"> <div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
@@ -333,38 +318,41 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
)} )}
<div <div
className={classNames('pt-6 px-2 sm:px-6', { className={classNames('pt-6 px-2 sm:px-6', {
'h-full flex flex-col': chatStarted, 'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
})} })}
ref={scrollRef}
> >
<ClientOnly> <ClientOnly>
{() => { {() => {
return chatStarted ? ( return chatStarted ? (
<Messages <div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
ref={messageRef} <Messages
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1" ref={messageRef}
messages={messages} className="flex flex-col "
isStreaming={isStreaming} messages={messages}
/> isStreaming={isStreaming}
/>
</div>
) : null; ) : null;
}} }}
</ClientOnly> </ClientOnly>
<div <div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', { className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
'sticky bottom-2': chatStarted, 'sticky bottom-2': chatStarted,
'position-absolute': chatStarted,
})} })}
> >
<div className="bg-bolt-elements-background-depth-2"> {actionAlert && (
{actionAlert && ( <ChatAlert
<ChatAlert alert={actionAlert}
alert={actionAlert} clearAlert={() => clearAlert?.()}
clearAlert={() => clearAlert?.()} postMessage={(message) => {
postMessage={(message) => { sendMessage?.({} as any, message);
sendMessage?.({} as any, message); clearAlert?.();
clearAlert?.(); }}
}} />
/> )}
)} {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
</div>
<div <div
className={classNames( className={classNames(
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt', 'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
@@ -417,7 +405,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
apiKeys={apiKeys} apiKeys={apiKeys}
modelLoading={isModelLoading} modelLoading={isModelLoading}
/> />
{(providerList || []).length > 0 && provider && ( {(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
<APIKeyManager <APIKeyManager
provider={provider} provider={provider}
apiKey={apiKeys[provider.name] || ''} apiKey={apiKeys[provider.name] || ''}
@@ -597,15 +585,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col justify-center gap-5"> {!chatStarted && (
{!chatStarted && ( <div className="flex flex-col justify-center mt-6 gap-5">
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{ImportButtons(importChat)} <div className="flex items-center gap-2">
<GitCloneButton importChat={importChat} /> {ImportButtons(importChat)}
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
</div>
</div> </div>
)}
{!chatStarted && {ExamplePrompts((event, messageInput) => {
ExamplePrompts((event, messageInput) => {
if (isStreaming) { if (isStreaming) {
handleStop?.(); handleStop?.();
return; return;
@@ -613,8 +602,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput); handleSendMessage?.(event, messageInput);
})} })}
{!chatStarted && <StarterTemplates />} <StarterTemplates />
</div> </div>
)}
</div> </div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly> <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div> </div>

View File

@@ -23,6 +23,7 @@ import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react'; import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler'; import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate'; import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
import { logStore } from '~/lib/stores/logs';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@@ -114,8 +115,8 @@ export const ChatImpl = memo(
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here const [imageDataList, setImageDataList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false); const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files); const files = useStore(workbenchStore.files);
@@ -137,7 +138,20 @@ export const ChatImpl = memo(
const [apiKeys, setApiKeys] = useState<Record<string, string>>({}); const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const { messages, isLoading, input, handleInputChange, setInput, stop, append, setMessages, reload } = useChat({ const {
messages,
isLoading,
input,
handleInputChange,
setInput,
stop,
append,
setMessages,
reload,
error,
data: chatData,
setData,
} = useChat({
api: '/api/chat', api: '/api/chat',
body: { body: {
apiKeys, apiKeys,
@@ -146,19 +160,31 @@ export const ChatImpl = memo(
contextOptimization: contextOptimizationEnabled, contextOptimization: contextOptimizationEnabled,
}, },
sendExtraMessageFields: true, sendExtraMessageFields: true,
onError: (error) => { onError: (e) => {
logger.error('Request failed\n\n', error); logger.error('Request failed\n\n', e, error);
logStore.logError('Chat request failed', e, {
component: 'Chat',
action: 'request',
error: e.message,
});
toast.error( toast.error(
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), 'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
); );
}, },
onFinish: (message, response) => { onFinish: (message, response) => {
const usage = response.usage; const usage = response.usage;
setData(undefined);
if (usage) { if (usage) {
console.log('Token usage:', usage); console.log('Token usage:', usage);
logStore.logProvider('Chat response completed', {
// You can now use the usage data as needed component: 'Chat',
action: 'response',
model,
provider: provider.name,
usage,
messageLength: message.content.length,
});
} }
logger.debug('Finished streaming'); logger.debug('Finished streaming');
@@ -217,6 +243,13 @@ export const ChatImpl = memo(
stop(); stop();
chatStore.setKey('aborted', true); chatStore.setKey('aborted', true);
workbenchStore.abortAllActions(); workbenchStore.abortAllActions();
logStore.logProvider('Chat response aborted', {
component: 'Chat',
action: 'abort',
model,
provider: provider.name,
});
}; };
useEffect(() => { useEffect(() => {
@@ -248,29 +281,69 @@ export const ChatImpl = memo(
}; };
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const _input = messageInput || input; const messageContent = messageInput || input;
if (_input.length === 0 || isLoading) { if (!messageContent?.trim()) {
return; return;
} }
/** if (isLoading) {
* @note (delm) Usually saving files shouldn't take long but it may take longer if there abort();
* many unsaved files. In that case we need to block user input and show an indicator return;
* of some kind so the user is aware that something is happening. But I consider the }
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
runAnimation(); runAnimation();
if (!chatStarted && messageInput && autoSelectTemplate) { if (!chatStarted) {
setFakeLoading(true); setFakeLoading(true);
if (autoSelectTemplate) {
const { template, title } = await selectStarterTemplate({
message: messageContent,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title).catch((e) => {
if (e.message.includes('rate limit')) {
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
} else {
toast.warning('Failed to import starter template\n Continuing with blank template');
}
return null;
});
if (temResp) {
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: messageContent,
},
{
id: `${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setFakeLoading(false);
return;
}
}
}
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
setMessages([ setMessages([
{ {
id: `${new Date().getTime()}`, id: `${new Date().getTime()}`,
@@ -278,127 +351,44 @@ export const ChatImpl = memo(
content: [ content: [
{ {
type: 'text', type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`, text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
}, },
...imageDataList.map((imageData) => ({ ...imageDataList.map((imageData) => ({
type: 'image', type: 'image',
image: imageData, image: imageData,
})), })),
] as any, // Type assertion to bypass compiler check ] as any,
}, },
]); ]);
reload();
setFakeLoading(false);
// reload(); return;
const { template, title } = await selectStarterTemplate({
message: messageInput,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title);
if (temResp) {
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: messageInput,
// annotations: ['hidden'],
},
{
id: `${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setFakeLoading(false);
return;
} else {
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
},
]);
reload();
setFakeLoading(false);
return;
}
} else {
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
},
]);
reload();
setFakeLoading(false);
return;
}
} }
if (error != null) {
setMessages(messages.slice(0, -1));
}
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
if (fileModifications !== undefined) { if (fileModifications !== undefined) {
/**
* If we have file modifications we append a new user message manually since we have to prefix
* the user input with the file modifications and we don't want the new user input to appear
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
* manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here.
*/
append({ append({
role: 'user', role: 'user',
content: [ content: [
{ {
type: 'text', type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`, text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
}, },
...imageDataList.map((imageData) => ({ ...imageDataList.map((imageData) => ({
type: 'image', type: 'image',
image: imageData, image: imageData,
})), })),
] as any, // Type assertion to bypass compiler check ] as any,
}); });
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications(); workbenchStore.resetAllFileModifications();
} else { } else {
append({ append({
@@ -406,20 +396,19 @@ export const ChatImpl = memo(
content: [ content: [
{ {
type: 'text', type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`, text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
}, },
...imageDataList.map((imageData) => ({ ...imageDataList.map((imageData) => ({
type: 'image', type: 'image',
image: imageData, image: imageData,
})), })),
] as any, // Type assertion to bypass compiler check ] as any,
}); });
} }
setInput(''); setInput('');
Cookies.remove(PROMPT_COOKIE_KEY); Cookies.remove(PROMPT_COOKIE_KEY);
// Add file cleanup here
setUploadedFiles([]); setUploadedFiles([]);
setImageDataList([]); setImageDataList([]);
@@ -522,6 +511,7 @@ export const ChatImpl = memo(
setImageDataList={setImageDataList} setImageDataList={setImageDataList}
actionAlert={actionAlert} actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()} clearAlert={() => workbenchStore.clearAlert()}
data={chatData}
/> />
); );
}, },

View File

@@ -1,23 +1,26 @@
import ignore from 'ignore'; import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit'; import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai'; import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands'; import { detectProjectCommands, createCommandsMessage, escapeBoltTags } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils'; import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import type { IChatMetadata } from '~/lib/persistence/db';
const IGNORE_PATTERNS = [ const IGNORE_PATTERNS = [
'node_modules/**', 'node_modules/**',
'.git/**', '.git/**',
'.github/**', '.github/**',
'.vscode/**', '.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**', 'dist/**',
'build/**', 'build/**',
'.next/**', '.next/**',
'coverage/**', 'coverage/**',
'.cache/**', '.cache/**',
'.vscode/**',
'.idea/**', '.idea/**',
'**/*.log', '**/*.log',
'**/.DS_Store', '**/.DS_Store',
@@ -30,54 +33,100 @@ const IGNORE_PATTERNS = [
const ig = ignore().add(IGNORE_PATTERNS); const ig = ignore().add(IGNORE_PATTERNS);
const MAX_FILE_SIZE = 100 * 1024; // 100KB limit per file
const MAX_TOTAL_SIZE = 500 * 1024; // 500KB total limit
interface GitCloneButtonProps { interface GitCloneButtonProps {
className?: string; className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>; importChat?: (description: string, messages: Message[], metadata?: IChatMetadata) => Promise<void>;
} }
export default function GitCloneButton({ importChat }: GitCloneButtonProps) { export default function GitCloneButton({ importChat, className }: GitCloneButtonProps) {
const { ready, gitClone } = useGit(); const { ready, gitClone } = useGit();
const onClick = async (_e: any) => { const [loading, setLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleClone = async (repoUrl: string) => {
if (!ready) { if (!ready) {
return; return;
} }
const repoUrl = prompt('Enter the Git url'); setLoading(true);
if (repoUrl) { try {
const { workdir, data } = await gitClone(repoUrl); const { workdir, data } = await gitClone(repoUrl);
if (importChat) { if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8'); const textDecoder = new TextDecoder('utf-8');
// Convert files to common format for command detection let totalSize = 0;
const fileContents = filePaths const skippedFiles: string[] = [];
.map((filePath) => { const fileContents = [];
const { data: content, encoding } = data[filePath];
return { for (const filePath of filePaths) {
path: filePath, const { data: content, encoding } = data[filePath];
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
}; // Skip binary files
}) if (
.filter((f) => f.content); content instanceof Uint8Array &&
!filePath.match(/\.(txt|md|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
) {
skippedFiles.push(filePath);
continue;
}
try {
const textContent =
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '';
if (!textContent) {
continue;
}
// Check file size
const fileSize = new TextEncoder().encode(textContent).length;
if (fileSize > MAX_FILE_SIZE) {
skippedFiles.push(`${filePath} (too large: ${Math.round(fileSize / 1024)}KB)`);
continue;
}
// Check total size
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
skippedFiles.push(`${filePath} (would exceed total size limit)`);
continue;
}
totalSize += fileSize;
fileContents.push({
path: filePath,
content: textContent,
});
} catch (e: any) {
skippedFiles.push(`${filePath} (error: ${e.message})`);
}
}
// Detect and create commands message
const commands = await detectProjectCommands(fileContents); const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands); const commandsMessage = createCommandsMessage(commands);
// Create files message
const filesMessage: Message = { const filesMessage: Message = {
role: 'assistant', role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir} content: `Cloning the repo ${repoUrl} into ${workdir}
${
skippedFiles.length > 0
? `\nSkipped files (${skippedFiles.length}):
${skippedFiles.map((f) => `- ${f}`).join('\n')}`
: ''
}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled"> <boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents ${fileContents
.map( .map(
(file) => (file) =>
`<boltAction type="file" filePath="${file.path}"> `<boltAction type="file" filePath="${file.path}">
${file.content} ${escapeBoltTags(file.content)}
</boltAction>`, </boltAction>`,
) )
.join('\n')} .join('\n')}
@@ -94,17 +143,39 @@ ${file.content}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
} }
} catch (error) {
console.error('Error during import:', error);
toast.error('Failed to import repository');
} finally {
setLoading(false);
} }
}; };
return ( return (
<button <>
onClick={onClick} <Button
title="Clone a Git Repo" onClick={() => setIsDialogOpen(true)}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2" title="Clone a Git Repo"
> variant="outline"
<span className="i-ph:git-branch" /> size="lg"
Clone a Git Repo className={classNames(
</button> 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out',
className,
)}
disabled={!ready || loading}
>
<span className="i-ph:git-branch w-4 h-4" />
Clone a Git Repo
</Button>
<RepositorySelectionDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onSelect={handleClone} />
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
</>
); );
} }

View File

@@ -4,6 +4,8 @@ import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils'; import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport'; import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
interface ImportFolderButtonProps { interface ImportFolderButtonProps {
className?: string; className?: string;
@@ -112,17 +114,28 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
onChange={handleFileChange} onChange={handleFileChange}
{...({} as any)} {...({} as any)}
/> />
<button <Button
onClick={() => { onClick={() => {
const input = document.getElementById('folder-import'); const input = document.getElementById('folder-import');
input?.click(); input?.click();
}} }}
className={className} title="Import Folder"
variant="outline"
size="lg"
className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out',
className,
)}
disabled={isLoading} disabled={isLoading}
> >
<div className="i-ph:upload-simple" /> <span className="i-ph:upload-simple w-4 h-4" />
{isLoading ? 'Importing...' : 'Import Folder'} {isLoading ? 'Importing...' : 'Import Folder'}
</button> </Button>
</> </>
); );
}; };

View File

@@ -7,6 +7,7 @@ import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss'; import styles from './Markdown.module.scss';
import ThoughtBox from './ThoughtBox';
const logger = createScopedLogger('MarkdownComponent'); const logger = createScopedLogger('MarkdownComponent');
@@ -32,6 +33,10 @@ export const Markdown = memo(({ children, html = false, limitedMarkdown = false
return <Artifact messageId={messageId} />; return <Artifact messageId={messageId} />;
} }
if (className?.includes('__boltThought__')) {
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
}
return ( return (
<div className={className} {...props}> <div className={className} {...props}>
{children} {children}

View File

@@ -1,5 +1,5 @@
import type { Message } from 'ai'; import type { Message } from 'ai';
import React, { Fragment } from 'react'; import { Fragment } from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage'; import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage'; import { UserMessage } from './UserMessage';
@@ -8,6 +8,10 @@ import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db'; import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip'; import WithTooltip from '~/components/ui/Tooltip';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
import { forwardRef } from 'react';
import type { ForwardedRef } from 'react';
interface MessagesProps { interface MessagesProps {
id?: string; id?: string;
@@ -16,100 +20,113 @@ interface MessagesProps {
messages?: Message[]; messages?: Message[];
} }
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => { export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
const { id, isStreaming = false, messages = [] } = props; (props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
const location = useLocation(); const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const profile = useStore(profileStore);
const handleRewind = (messageId: string) => { const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
searchParams.set('rewindTo', messageId); searchParams.set('rewindTo', messageId);
window.location.search = searchParams.toString(); window.location.search = searchParams.toString();
}; };
const handleFork = async (messageId: string) => { const handleFork = async (messageId: string) => {
try { try {
if (!db || !chatId.get()) { if (!db || !chatId.get()) {
toast.error('Chat persistence is not available'); toast.error('Chat persistence is not available');
return; return;
}
const urlId = await forkChat(db, chatId.get()!, messageId);
window.location.href = `/chat/${urlId}`;
} catch (error) {
toast.error('Failed to fork chat: ' + (error as Error).message);
} }
};
const urlId = await forkChat(db, chatId.get()!, messageId); return (
window.location.href = `/chat/${urlId}`; <div id={id} className={props.className} ref={ref}>
} catch (error) { {messages.length > 0
toast.error('Failed to fork chat: ' + (error as Error).message); ? messages.map((message, index) => {
} const { role, content, id: messageId, annotations } = message;
}; const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
const isHidden = annotations?.includes('hidden');
return ( if (isHidden) {
<div id={id} ref={ref} className={props.className}> return <Fragment key={index} />;
{messages.length > 0 }
? messages.map((message, index) => {
const { role, content, id: messageId, annotations } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
const isHidden = annotations?.includes('hidden');
if (isHidden) { return (
return <Fragment key={index} />; <div
} key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
return ( 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
<div 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
key={index} isStreaming && isLast,
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', { 'mt-4': !isFirst,
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast), })}
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent': >
isStreaming && isLast, {isUserMessage && (
'mt-4': !isFirst, <div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
})} {profile?.avatar ? (
> <img
{isUserMessage && ( src={profile.avatar}
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start"> alt={profile?.username || 'User'}
<div className="i-ph:user-fill text-xl"></div> className="w-full h-full object-cover"
</div> loading="eager"
)} decoding="sync"
<div className="grid grid-col-1 w-full"> />
{isUserMessage ? ( ) : (
<UserMessage content={content} /> <div className="i-ph:user-fill text-2xl" />
) : ( )}
<AssistantMessage content={content} annotations={message.annotations} /> </div>
)} )}
</div> <div className="grid grid-col-1 w-full">
{!isUserMessage && ( {isUserMessage ? (
<div className="flex gap-2 flex-col lg:flex-row"> <UserMessage content={content} />
{messageId && ( ) : (
<WithTooltip tooltip="Revert to this message"> <AssistantMessage content={content} annotations={message.annotations} />
)}
</div>
{!isUserMessage && (
<div className="flex gap-2 flex-col lg:flex-row">
{messageId && (
<WithTooltip tooltip="Revert to this message">
<button
onClick={() => handleRewind(messageId)}
key="i-ph:arrow-u-up-left"
className={classNames(
'i-ph:arrow-u-up-left',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
)}
<WithTooltip tooltip="Fork chat from this message">
<button <button
onClick={() => handleRewind(messageId)} onClick={() => handleFork(messageId)}
key="i-ph:arrow-u-up-left" key="i-ph:git-fork"
className={classNames( className={classNames(
'i-ph:arrow-u-up-left', 'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors', 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)} )}
/> />
</WithTooltip> </WithTooltip>
)} </div>
)}
<WithTooltip tooltip="Fork chat from this message"> </div>
<button );
onClick={() => handleFork(messageId)} })
key="i-ph:git-fork" : null}
className={classNames( {isStreaming && (
'i-ph:git-fork', <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors', )}
)} </div>
/> );
</WithTooltip> },
</div> );
)}
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
);
});

View File

@@ -1,6 +1,9 @@
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
import { useEffect } from 'react'; import { useEffect, useState, useRef } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types'; import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
import * as React from 'react';
interface ModelSelectorProps { interface ModelSelectorProps {
model?: string; model?: string;
@@ -22,12 +25,118 @@ export const ModelSelector = ({
providerList, providerList,
modelLoading, modelLoading,
}: ModelSelectorProps) => { }: ModelSelectorProps) => {
// Load enabled providers from cookies const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter models based on search query
const filteredModels = [...modelList]
.filter((e) => e.provider === provider?.name && e.name)
.filter(
(model) =>
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
);
// Reset focused index when search query changes or dropdown opens/closes
useEffect(() => {
setFocusedIndex(-1);
}, [modelSearchQuery, isModelDropdownOpen]);
// Focus search input when dropdown opens
useEffect(() => {
if (isModelDropdownOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isModelDropdownOpen]);
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isModelDropdownOpen) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) => {
const next = prev + 1;
if (next >= filteredModels.length) {
return 0;
}
return next;
});
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) => {
const next = prev - 1;
if (next < 0) {
return filteredModels.length - 1;
}
return next;
});
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
const selectedModel = filteredModels[focusedIndex];
setModel?.(selectedModel.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}
break;
case 'Escape':
e.preventDefault();
setIsModelDropdownOpen(false);
setModelSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false);
}
break;
}
};
// Focus the selected option
useEffect(() => {
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
// Update enabled providers when cookies change // Update enabled providers when cookies change
useEffect(() => { useEffect(() => {
// If current provider is disabled, switch to first enabled provider // If current provider is disabled, switch to first enabled provider
if (providerList.length == 0) { if (providerList.length === 0) {
return; return;
} }
@@ -80,27 +189,124 @@ export const ModelSelector = ({
</option> </option>
))} ))}
</select> </select>
<select
key={provider?.name} <div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
value={model} <div
onChange={(e) => setModel?.(e.target.value)} className={classNames(
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]" 'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
disabled={modelLoading === 'all' || modelLoading === provider?.name} 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
> 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
{modelLoading == 'all' || modelLoading == provider?.name ? ( 'transition-all cursor-pointer',
<option key={0} value=""> isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
Loading... )}
</option> onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
) : ( onKeyDown={(e) => {
[...modelList] if (e.key === 'Enter' || e.key === ' ') {
.filter((e) => e.provider == provider?.name && e.name) e.preventDefault();
.map((modelOption, index) => ( setIsModelDropdownOpen(!isModelDropdownOpen);
<option key={index} value={modelOption.name}> }
{modelOption.label} }}
</option> role="combobox"
)) aria-expanded={isModelDropdownOpen}
aria-controls="model-listbox"
aria-haspopup="listbox"
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
isModelDropdownOpen ? 'rotate-180' : undefined,
)}
/>
</div>
</div>
{isModelDropdownOpen && (
<div
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
role="listbox"
id="model-listbox"
>
<div className="px-2 pb-2">
<div className="relative">
<input
ref={searchInputRef}
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
className={classNames(
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
'transition-all',
)}
onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search models"
/>
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
</div>
</div>
<div
className={classNames(
'max-h-60 overflow-y-auto',
'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full',
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
)}
>
{modelLoading === 'all' || modelLoading === provider?.name ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
) : filteredModels.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No models found</div>
) : (
filteredModels.map((modelOption, index) => (
<div
ref={(el) => (optionsRef.current[index] = el)}
key={index}
role="option"
aria-selected={model === modelOption.name}
className={classNames(
'px-3 py-2 text-sm cursor-pointer',
'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary',
'outline-none',
model === modelOption.name || focusedIndex === index
? 'bg-bolt-elements-background-depth-2'
: undefined,
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
)}
onClick={(e) => {
e.stopPropagation();
setModel?.(modelOption.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}}
tabIndex={focusedIndex === index ? 0 : -1}
>
{modelOption.label}
</div>
))
)}
</div>
</div>
)} )}
</select> </div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,111 @@
import { AnimatePresence, motion } from 'framer-motion';
import React, { useState } from 'react';
import type { ProgressAnnotation } from '~/types/context';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
export default function ProgressCompilation({ data }: { data?: ProgressAnnotation[] }) {
const [progressList, setProgressList] = React.useState<ProgressAnnotation[]>([]);
const [expanded, setExpanded] = useState(false);
React.useEffect(() => {
if (!data || data.length == 0) {
setProgressList([]);
return;
}
const progressMap = new Map<string, ProgressAnnotation>();
data.forEach((x) => {
const existingProgress = progressMap.get(x.label);
if (existingProgress && existingProgress.status === 'complete') {
return;
}
progressMap.set(x.label, x);
});
const newData = Array.from(progressMap.values());
newData.sort((a, b) => a.order - b.order);
setProgressList(newData);
}, [data]);
if (progressList.length === 0) {
return <></>;
}
return (
<AnimatePresence>
<div
className={classNames(
'bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
'p-1',
)}
style={{ transform: 'translateY(1rem)' }}
>
<div
className={classNames(
'bg-bolt-elements-item-backgroundAccent',
'p-1 rounded-lg text-bolt-elements-item-contentAccent',
'flex ',
)}
>
<div className="flex-1">
<AnimatePresence>
{expanded ? (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
{progressList.map((x, i) => {
return <ProgressItem key={i} progress={x} />;
})}
</motion.div>
) : (
<ProgressItem progress={progressList.slice(-1)[0]} />
)}
</AnimatePresence>
</div>
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className=" p-1 rounded-lg bg-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={() => setExpanded((v) => !v)}
>
<div className={expanded ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</motion.button>
</div>
</div>
</AnimatePresence>
);
}
const ProgressItem = ({ progress }: { progress: ProgressAnnotation }) => {
return (
<motion.div
className={classNames('flex text-sm gap-3')}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="flex items-center gap-1.5 ">
<div>
{progress.status === 'in-progress' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : progress.status === 'complete' ? (
<div className="i-ph:check"></div>
) : null}
</div>
{/* {x.label} */}
</div>
{progress.message}
</motion.div>
);
};

View File

@@ -11,15 +11,24 @@ const FrameworkLink: React.FC<FrameworkLinkProps> = ({ template }) => (
href={`/git?url=https://github.com/${template.githubRepo}.git`} href={`/git?url=https://github.com/${template.githubRepo}.git`}
data-state="closed" data-state="closed"
data-discover="true" data-discover="true"
className="items-center justify-center " className="items-center justify-center"
> >
<div <div
className={`inline-block ${template.icon} w-8 h-8 text-4xl transition-theme opacity-25 hover:opacity-75 transition-all`} className={`inline-block ${template.icon} w-8 h-8 text-4xl transition-theme opacity-25 hover:opacity-100 hover:text-purple-500 dark:text-white dark:opacity-50 dark:hover:opacity-100 dark:hover:text-purple-400 transition-all`}
title={template.label}
/> />
</a> </a>
); );
const StarterTemplates: React.FC = () => { const StarterTemplates: React.FC = () => {
// Debug: Log available templates and their icons
React.useEffect(() => {
console.log(
'Available templates:',
STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })),
);
}, []);
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<span className="text-sm text-gray-500">or start a blank app with your favorite stack</span> <span className="text-sm text-gray-500">or start a blank app with your favorite stack</span>

View File

@@ -0,0 +1,43 @@
import { useState, type PropsWithChildren } from 'react';
const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
onClick={() => setIsExpanded(!isExpanded)}
className={`
bg-bolt-elements-background-depth-2
shadow-md
rounded-lg
cursor-pointer
transition-all
duration-300
${isExpanded ? 'max-h-96' : 'max-h-13'}
overflow-auto
border border-bolt-elements-borderColor
`}
>
<div className="p-4 flex items-center gap-4 rounded-lg text-bolt-elements-textSecondary font-medium leading-5 text-sm border border-bolt-elements-borderColor">
<div className="i-ph:brain-thin text-2xl" />
<div className="div">
<span> {title}</span>{' '}
{!isExpanded && <span className="text-bolt-elements-textTertiary"> - Click to expand</span>}
</div>
</div>
<div
className={`
transition-opacity
duration-300
p-4
rounded-lg
${isExpanded ? 'opacity-100' : 'opacity-0'}
`}
>
{children}
</div>
</div>
);
};
export default ThoughtBox;

View File

@@ -1,6 +1,13 @@
import type { Message } from 'ai'; import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton'; import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
type ChatData = {
messages?: Message[]; // Standard Bolt format
description?: string; // Optional description
};
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) { export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
return ( return (
@@ -20,14 +27,17 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
reader.onload = async (e) => { reader.onload = async (e) => {
try { try {
const content = e.target?.result as string; const content = e.target?.result as string;
const data = JSON.parse(content); const data = JSON.parse(content) as ChatData;
if (!Array.isArray(data.messages)) { // Standard format
toast.error('Invalid chat file format'); if (Array.isArray(data.messages)) {
await importChat(data.description || 'Imported Chat', data.messages);
toast.success('Chat imported successfully');
return;
} }
await importChat(data.description, data.messages); toast.error('Invalid chat file format');
toast.success('Chat imported successfully');
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
toast.error('Failed to parse chat file: ' + error.message); toast.error('Failed to parse chat file: ' + error.message);
@@ -49,19 +59,35 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
/> />
<div className="flex flex-col items-center gap-4 max-w-2xl text-center"> <div className="flex flex-col items-center gap-4 max-w-2xl text-center">
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
onClick={() => { onClick={() => {
const input = document.getElementById('chat-import'); const input = document.getElementById('chat-import');
input?.click(); input?.click();
}} }}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2" variant="outline"
size="lg"
className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out',
)}
> >
<div className="i-ph:upload-simple" /> <span className="i-ph:upload-simple w-4 h-4" />
Import Chat Import Chat
</button> </Button>
<ImportFolderButton <ImportFolderButton
importChat={importChat} importChat={importChat}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2" className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out rounded-lg',
)}
/> />
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@ import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client'; import { Chat } from '~/components/chat/Chat.client';
import { useGit } from '~/lib/hooks/useGit'; import { useGit } from '~/lib/hooks/useGit';
import { useChatHistory } from '~/lib/persistence'; import { useChatHistory } from '~/lib/persistence';
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands'; import { createCommandsMessage, detectProjectCommands, escapeBoltTags } from '~/utils/projectCommands';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay'; import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -49,53 +49,65 @@ export function GitUrlImport() {
if (repoUrl) { if (repoUrl) {
const ig = ignore().add(IGNORE_PATTERNS); const ig = ignore().add(IGNORE_PATTERNS);
const { workdir, data } = await gitClone(repoUrl);
if (importChat) { try {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); const { workdir, data } = await gitClone(repoUrl);
const textDecoder = new TextDecoder('utf-8'); if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
const textDecoder = new TextDecoder('utf-8');
// Convert files to common format for command detection const fileContents = filePaths
const fileContents = filePaths .map((filePath) => {
.map((filePath) => { const { data: content, encoding } = data[filePath];
const { data: content, encoding } = data[filePath]; return {
return { path: filePath,
path: filePath, content:
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
}; };
}) })
.filter((f) => f.content); .filter((f) => f.content);
// Detect and create commands message const commands = await detectProjectCommands(fileContents);
const commands = await detectProjectCommands(fileContents); const commandsMessage = createCommandsMessage(commands);
const commandsMessage = createCommandsMessage(commands);
// Create files message const filesMessage: Message = {
const filesMessage: Message = { role: 'assistant',
role: 'assistant', content: `Cloning the repo ${repoUrl} into ${workdir}
content: `Cloning the repo ${repoUrl} into ${workdir} <boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents ${fileContents
.map( .map(
(file) => (file) =>
`<boltAction type="file" filePath="${file.path}"> `<boltAction type="file" filePath="${file.path}">
${file.content} ${escapeBoltTags(file.content)}
</boltAction>`, </boltAction>`,
) )
.join('\n')} .join('\n')}
</boltArtifact>`, </boltArtifact>`,
id: generateId(), id: generateId(),
createdAt: new Date(), createdAt: new Date(),
}; };
const messages = [filesMessage]; const messages = [filesMessage];
if (commandsMessage) { if (commandsMessage) {
messages.push(commandsMessage); messages.push({
role: 'user',
id: generateId(),
content: 'Setup the codebase and Start the application',
});
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages, { gitUrl: repoUrl });
} }
} catch (error) {
console.error('Error during import:', error);
toast.error('Failed to import repository');
setLoading(false);
window.location.href = '/';
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); return;
} }
} }
}; };

View File

@@ -1,63 +0,0 @@
.settings-tabs {
button {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
text-align: left;
font-size: 0.875rem;
transition: all 0.2s;
margin-bottom: 0.5rem;
&.active {
background: var(--bolt-elements-button-primary-background);
color: var(--bolt-elements-textPrimary);
}
&:not(.active) {
background: var(--bolt-elements-bg-depth-3);
color: var(--bolt-elements-textPrimary);
&:hover {
background: var(--bolt-elements-button-primary-backgroundHover);
}
}
}
}
.settings-button {
background-color: var(--bolt-elements-button-primary-background);
color: var(--bolt-elements-textPrimary);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--bolt-elements-button-primary-backgroundHover);
}
}
.settings-danger-area {
background-color: transparent;
color: var(--bolt-elements-textPrimary);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
border-style: solid;
border-color: var(--bolt-elements-button-danger-backgroundHover);
border-width: thin;
button {
background-color: var(--bolt-elements-button-danger-background);
color: var(--bolt-elements-button-danger-text);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--bolt-elements-button-danger-backgroundHover);
}
}
}

View File

@@ -1,128 +0,0 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState, type ReactElement } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog';
import { IconButton } from '~/components/ui/IconButton';
import styles from './Settings.module.scss';
import ProvidersTab from './providers/ProvidersTab';
import { useSettings } from '~/lib/hooks/useSettings';
import FeaturesTab from './features/FeaturesTab';
import DebugTab from './debug/DebugTab';
import EventLogsTab from './event-logs/EventLogsTab';
import ConnectionsTab from './connections/ConnectionsTab';
import DataTab from './data/DataTab';
interface SettingsProps {
open: boolean;
onClose: () => void;
}
type TabType = 'data' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
const { debug, eventLogs } = useSettings();
const [activeTab, setActiveTab] = useState<TabType>('data');
const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
{ id: 'data', label: 'Data', icon: 'i-ph:database', component: <DataTab /> },
{ id: 'providers', label: 'Providers', icon: 'i-ph:key', component: <ProvidersTab /> },
{ id: 'connection', label: 'Connection', icon: 'i-ph:link', component: <ConnectionsTab /> },
{ id: 'features', label: 'Features', icon: 'i-ph:star', component: <FeaturesTab /> },
...(debug
? [
{
id: 'debug' as TabType,
label: 'Debug Tab',
icon: 'i-ph:bug',
component: <DebugTab />,
},
]
: []),
...(eventLogs
? [
{
id: 'event-logs' as TabType,
label: 'Event Logs',
icon: 'i-ph:list-bullets',
component: <EventLogsTab />,
},
]
: []),
];
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild onClick={onClose}>
<motion.div
className="bg-black/50 fixed inset-0 z-max backdrop-blur-sm"
initial="closed"
animate="open"
exit="closed"
variants={dialogBackdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content aria-describedby={undefined} asChild>
<motion.div
className="fixed top-[50%] left-[50%] z-max h-[85vh] w-[90vw] max-w-[900px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg shadow-lg focus:outline-none overflow-hidden"
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<div className="flex h-full">
<div
className={classNames(
'w-48 border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 p-4 flex flex-col justify-between',
styles['settings-tabs'],
)}
>
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-bolt-elements-textPrimary mb-2">
Settings
</DialogTitle>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={classNames(activeTab === tab.id ? styles.active : '')}
>
<div className={tab.icon} />
{tab.label}
</button>
))}
<div className="mt-auto flex flex-col gap-2">
<a
href="https://github.com/stackblitz-labs/bolt.diy"
target="_blank"
rel="noopener noreferrer"
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
>
<div className="i-ph:github-logo" />
GitHub
</a>
<a
href="https://stackblitz-labs.github.io/bolt.diy/"
target="_blank"
rel="noopener noreferrer"
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
>
<div className="i-ph:book" />
Docs
</a>
</div>
</div>
<div className="flex-1 flex flex-col p-8 pt-10 bg-bolt-elements-background-depth-2">
<div className="flex-1 overflow-y-auto">{tabs.find((tab) => tab.id === activeTab)?.component}</div>
</div>
</div>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
</RadixDialog.Close>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@@ -1,151 +0,0 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
import { logStore } from '~/lib/stores/logs';
interface GitHubUserResponse {
login: string;
id: number;
[key: string]: any; // for other properties we don't explicitly need
}
export default function ConnectionsTab() {
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
const [isConnected, setIsConnected] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
useEffect(() => {
// Check if credentials exist and verify them
if (githubUsername && githubToken) {
verifyGitHubCredentials();
}
}, []);
const verifyGitHubCredentials = async () => {
setIsVerifying(true);
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${githubToken}`,
},
});
if (response.ok) {
const data = (await response.json()) as GitHubUserResponse;
if (data.login === githubUsername) {
setIsConnected(true);
return true;
}
}
setIsConnected(false);
return false;
} catch (error) {
console.error('Error verifying GitHub credentials:', error);
setIsConnected(false);
return false;
} finally {
setIsVerifying(false);
}
};
const handleSaveConnection = async () => {
if (!githubUsername || !githubToken) {
toast.error('Please provide both GitHub username and token');
return;
}
setIsVerifying(true);
const isValid = await verifyGitHubCredentials();
if (isValid) {
Cookies.set('githubUsername', githubUsername);
Cookies.set('githubToken', githubToken);
logStore.logSystem('GitHub connection settings updated', {
username: githubUsername,
hasToken: !!githubToken,
});
toast.success('GitHub credentials verified and saved successfully!');
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
setIsConnected(true);
} else {
toast.error('Invalid GitHub credentials. Please check your username and token.');
}
};
const handleDisconnect = () => {
Cookies.remove('githubUsername');
Cookies.remove('githubToken');
Cookies.remove('git:github.com');
setGithubUsername('');
setGithubToken('');
setIsConnected(false);
logStore.logSystem('GitHub connection removed');
toast.success('GitHub connection removed successfully!');
};
return (
<div className="p-4 mb-4 border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-3">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">GitHub Connection</h3>
<div className="flex mb-4">
<div className="flex-1 mr-2">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">GitHub Username:</label>
<input
type="text"
value={githubUsername}
onChange={(e) => setGithubUsername(e.target.value)}
disabled={isVerifying}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
/>
</div>
<div className="flex-1">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
<input
type="password"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
disabled={isVerifying}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
/>
</div>
</div>
<div className="flex mb-4 items-center">
{!isConnected ? (
<button
onClick={handleSaveConnection}
disabled={isVerifying || !githubUsername || !githubToken}
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 mr-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
{isVerifying ? (
<>
<div className="i-ph:spinner animate-spin mr-2" />
Verifying...
</>
) : (
'Connect'
)}
</button>
) : (
<button
onClick={handleDisconnect}
className="bg-bolt-elements-button-danger-background rounded-lg px-4 py-2 mr-2 transition-colors duration-200 hover:bg-bolt-elements-button-danger-backgroundHover text-bolt-elements-button-danger-text"
>
Disconnect
</button>
)}
{isConnected && (
<span className="text-sm text-green-600 flex items-center">
<div className="i-ph:check-circle mr-1" />
Connected to GitHub
</span>
)}
</div>
</div>
);
}

View File

@@ -1,305 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from '@remix-run/react';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
import { db, deleteById, getAll } from '~/lib/persistence';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
// List of supported providers that can have API keys
const API_KEY_PROVIDERS = [
'Anthropic',
'OpenAI',
'Google',
'Groq',
'HuggingFace',
'OpenRouter',
'Deepseek',
'Mistral',
'OpenAILike',
'Together',
'xAI',
'Perplexity',
'Cohere',
'AzureOpenAI',
] as const;
interface ApiKeys {
[key: string]: string;
}
export default function DataTab() {
const navigate = useNavigate();
const [isDeleting, setIsDeleting] = useState(false);
const downloadAsJson = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportAllChats = async () => {
if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to export chats - DB unavailable', error);
toast.error('Database is not available');
return;
}
try {
const allChats = await getAll(db);
const exportData = {
chats: allChats,
exportDate: new Date().toISOString(),
};
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
logStore.logSystem('Chats exported successfully', { count: allChats.length });
toast.success('Chats exported successfully');
} catch (error) {
logStore.logError('Failed to export chats', error);
toast.error('Failed to export chats');
console.error(error);
}
};
const handleDeleteAllChats = async () => {
const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
if (!confirmDelete) {
return;
}
if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to delete chats - DB unavailable', error);
toast.error('Database is not available');
return;
}
try {
setIsDeleting(true);
const allChats = await getAll(db);
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
logStore.logSystem('All chats deleted successfully', { count: allChats.length });
toast.success('All chats deleted successfully');
navigate('/', { replace: true });
} catch (error) {
logStore.logError('Failed to delete chats', error);
toast.error('Failed to delete chats');
console.error(error);
} finally {
setIsDeleting(false);
}
};
const handleExportSettings = () => {
const settings = {
providers: Cookies.get('providers'),
isDebugEnabled: Cookies.get('isDebugEnabled'),
isEventLogsEnabled: Cookies.get('isEventLogsEnabled'),
isLocalModelsEnabled: Cookies.get('isLocalModelsEnabled'),
promptId: Cookies.get('promptId'),
isLatestBranch: Cookies.get('isLatestBranch'),
commitHash: Cookies.get('commitHash'),
eventLogs: Cookies.get('eventLogs'),
selectedModel: Cookies.get('selectedModel'),
selectedProvider: Cookies.get('selectedProvider'),
githubUsername: Cookies.get('githubUsername'),
githubToken: Cookies.get('githubToken'),
bolt_theme: localStorage.getItem('bolt_theme'),
};
downloadAsJson(settings, 'bolt-settings.json');
toast.success('Settings exported successfully');
};
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const settings = JSON.parse(e.target?.result as string);
Object.entries(settings).forEach(([key, value]) => {
if (key === 'bolt_theme') {
if (value) {
localStorage.setItem(key, value as string);
}
} else if (value) {
Cookies.set(key, value as string);
}
});
toast.success('Settings imported successfully. Please refresh the page for changes to take effect.');
} catch (error) {
toast.error('Failed to import settings. Make sure the file is a valid JSON file.');
console.error('Failed to import settings:', error);
}
};
reader.readAsText(file);
event.target.value = '';
};
const handleExportApiKeyTemplate = () => {
const template: ApiKeys = {};
API_KEY_PROVIDERS.forEach((provider) => {
template[`${provider}_API_KEY`] = '';
});
template.OPENAI_LIKE_API_BASE_URL = '';
template.LMSTUDIO_API_BASE_URL = '';
template.OLLAMA_API_BASE_URL = '';
template.TOGETHER_API_BASE_URL = '';
downloadAsJson(template, 'api-keys-template.json');
toast.success('API keys template exported successfully');
};
const handleImportApiKeys = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const apiKeys = JSON.parse(e.target?.result as string);
let importedCount = 0;
const consolidatedKeys: Record<string, string> = {};
API_KEY_PROVIDERS.forEach((provider) => {
const keyName = `${provider}_API_KEY`;
if (apiKeys[keyName]) {
consolidatedKeys[provider] = apiKeys[keyName];
importedCount++;
}
});
if (importedCount > 0) {
// Store all API keys in a single cookie as JSON
Cookies.set('apiKeys', JSON.stringify(consolidatedKeys));
// Also set individual cookies for backward compatibility
Object.entries(consolidatedKeys).forEach(([provider, key]) => {
Cookies.set(`${provider}_API_KEY`, key);
});
toast.success(`Successfully imported ${importedCount} API keys/URLs. Refreshing page to apply changes...`);
// Reload the page after a short delay to allow the toast to be seen
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
toast.warn('No valid API keys found in the file');
}
// Set base URLs if they exist
['OPENAI_LIKE_API_BASE_URL', 'LMSTUDIO_API_BASE_URL', 'OLLAMA_API_BASE_URL', 'TOGETHER_API_BASE_URL'].forEach(
(baseUrl) => {
if (apiKeys[baseUrl]) {
Cookies.set(baseUrl, apiKeys[baseUrl]);
}
},
);
} catch (error) {
toast.error('Failed to import API keys. Make sure the file is a valid JSON file.');
console.error('Failed to import API keys:', error);
}
};
reader.readAsText(file);
event.target.value = '';
};
return (
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
<div className="mb-6">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Data Management</h3>
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div>
<h4 className="text-bolt-elements-textPrimary mb-2">Chat History</h4>
<p className="text-sm text-bolt-elements-textSecondary mb-4">Export or delete all your chat history.</p>
<div className="flex gap-4">
<button
onClick={handleExportAllChats}
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
>
Export All Chats
</button>
<button
onClick={handleDeleteAllChats}
disabled={isDeleting}
className={classNames(
'px-4 py-2 bg-bolt-elements-button-danger-background hover:bg-bolt-elements-button-danger-backgroundHover text-bolt-elements-button-danger-text rounded-lg transition-colors',
isDeleting ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
</button>
</div>
</div>
<div>
<h4 className="text-bolt-elements-textPrimary mb-2">Settings Backup</h4>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Export your settings to a JSON file or import settings from a previously exported file.
</p>
<div className="flex gap-4">
<button
onClick={handleExportSettings}
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
>
Export Settings
</button>
<label className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors cursor-pointer">
Import Settings
<input type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
</label>
</div>
</div>
<div>
<h4 className="text-bolt-elements-textPrimary mb-2">API Keys Management</h4>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Import API keys from a JSON file or download a template to fill in your keys.
</p>
<div className="flex gap-4">
<button
onClick={handleExportApiKeyTemplate}
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
>
Download Template
</button>
<label className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors cursor-pointer">
Import API Keys
<input type="file" accept=".json" onChange={handleImportApiKeys} className="hidden" />
</label>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,639 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
interface ProviderStatus {
name: string;
enabled: boolean;
isLocal: boolean;
isRunning: boolean | null;
error?: string;
lastChecked: Date;
responseTime?: number;
url: string | null;
}
interface SystemInfo {
os: string;
browser: string;
screen: string;
language: string;
timezone: string;
memory: string;
cores: number;
deviceType: string;
colorDepth: string;
pixelRatio: number;
online: boolean;
cookiesEnabled: boolean;
doNotTrack: boolean;
}
interface IProviderConfig {
name: string;
settings: {
enabled: boolean;
baseUrl?: string;
};
}
interface CommitData {
commit: string;
version?: string;
}
const connitJson: CommitData = {
commit: __COMMIT_HASH,
version: __APP_VERSION,
};
const LOCAL_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
const versionHash = connitJson.commit;
const versionTag = connitJson.version;
const GITHUB_URLS = {
original: 'https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/main',
fork: 'https://api.github.com/repos/Stijnus/bolt.new-any-llm/commits/main',
commitJson: async (branch: string) => {
try {
const response = await fetch(`https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/${branch}`);
const data: { sha: string } = await response.json();
const packageJsonResp = await fetch(
`https://raw.githubusercontent.com/stackblitz-labs/bolt.diy/${branch}/package.json`,
);
const packageJson: { version: string } = await packageJsonResp.json();
return {
commit: data.sha.slice(0, 7),
version: packageJson.version,
};
} catch (error) {
console.log('Failed to fetch local commit info:', error);
throw new Error('Failed to fetch local commit info');
}
},
};
function getSystemInfo(): SystemInfo {
const formatBytes = (bytes: number): string => {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getBrowserInfo = (): string => {
const ua = navigator.userAgent;
let browser = 'Unknown';
if (ua.includes('Firefox/')) {
browser = 'Firefox';
} else if (ua.includes('Chrome/')) {
if (ua.includes('Edg/')) {
browser = 'Edge';
} else if (ua.includes('OPR/')) {
browser = 'Opera';
} else {
browser = 'Chrome';
}
} else if (ua.includes('Safari/')) {
if (!ua.includes('Chrome')) {
browser = 'Safari';
}
}
// Extract version number
const match = ua.match(new RegExp(`${browser}\\/([\\d.]+)`));
const version = match ? ` ${match[1]}` : '';
return `${browser}${version}`;
};
const getOperatingSystem = (): string => {
const ua = navigator.userAgent;
const platform = navigator.platform;
if (ua.includes('Win')) {
return 'Windows';
}
if (ua.includes('Mac')) {
if (ua.includes('iPhone') || ua.includes('iPad')) {
return 'iOS';
}
return 'macOS';
}
if (ua.includes('Linux')) {
return 'Linux';
}
if (ua.includes('Android')) {
return 'Android';
}
return platform || 'Unknown';
};
const getDeviceType = (): string => {
const ua = navigator.userAgent;
if (ua.includes('Mobile')) {
return 'Mobile';
}
if (ua.includes('Tablet')) {
return 'Tablet';
}
return 'Desktop';
};
// Get more detailed memory info if available
const getMemoryInfo = (): string => {
if ('memory' in performance) {
const memory = (performance as any).memory;
return `${formatBytes(memory.jsHeapSizeLimit)} (Used: ${formatBytes(memory.usedJSHeapSize)})`;
}
return 'Not available';
};
return {
os: getOperatingSystem(),
browser: getBrowserInfo(),
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
memory: getMemoryInfo(),
cores: navigator.hardwareConcurrency || 0,
deviceType: getDeviceType(),
// Add new fields
colorDepth: `${window.screen.colorDepth}-bit`,
pixelRatio: window.devicePixelRatio,
online: navigator.onLine,
cookiesEnabled: navigator.cookieEnabled,
doNotTrack: navigator.doNotTrack === '1',
};
}
const checkProviderStatus = async (url: string | null, providerName: string): Promise<ProviderStatus> => {
if (!url) {
console.log(`[Debug] No URL provided for ${providerName}`);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: 'No URL configured',
lastChecked: new Date(),
url: null,
};
}
console.log(`[Debug] Checking status for ${providerName} at ${url}`);
const startTime = performance.now();
try {
if (providerName.toLowerCase() === 'ollama') {
// Special check for Ollama root endpoint
try {
console.log(`[Debug] Checking Ollama root endpoint: ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'text/plain,application/json',
},
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`[Debug] Ollama root response:`, text);
if (text.includes('Ollama is running')) {
console.log(`[Debug] Ollama running confirmed via root endpoint`);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: true,
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
} catch (error) {
console.log(`[Debug] Ollama root check failed:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('aborted')) {
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: 'Connection timeout',
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
}
}
// Try different endpoints based on provider
const checkUrls = [`${url}/api/health`, url.endsWith('v1') ? `${url}/models` : `${url}/v1/models`];
console.log(`[Debug] Checking additional endpoints:`, checkUrls);
const results = await Promise.all(
checkUrls.map(async (checkUrl) => {
try {
console.log(`[Debug] Trying endpoint: ${checkUrl}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(checkUrl, {
signal: controller.signal,
headers: {
Accept: 'application/json',
},
});
clearTimeout(timeoutId);
const ok = response.ok;
console.log(`[Debug] Endpoint ${checkUrl} response:`, ok);
if (ok) {
try {
const data = await response.json();
console.log(`[Debug] Endpoint ${checkUrl} data:`, data);
} catch {
console.log(`[Debug] Could not parse JSON from ${checkUrl}`);
}
}
return ok;
} catch (error) {
console.log(`[Debug] Endpoint ${checkUrl} failed:`, error);
return false;
}
}),
);
const isRunning = results.some((result) => result);
console.log(`[Debug] Final status for ${providerName}:`, isRunning);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning,
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
} catch (error) {
console.log(`[Debug] Provider check failed for ${providerName}:`, error);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
};
export default function DebugTab() {
const { providers, isLatestBranch } = useSettings();
const [activeProviders, setActiveProviders] = useState<ProviderStatus[]>([]);
const [updateMessage, setUpdateMessage] = useState<string>('');
const [systemInfo] = useState<SystemInfo>(getSystemInfo());
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const updateProviderStatuses = async () => {
if (!providers) {
return;
}
try {
const entries = Object.entries(providers) as [string, IProviderConfig][];
const statuses = await Promise.all(
entries
.filter(([, provider]) => LOCAL_PROVIDERS.includes(provider.name))
.map(async ([, provider]) => {
const envVarName =
providerBaseUrlEnvKeys[provider.name].baseUrlKey || `REACT_APP_${provider.name.toUpperCase()}_URL`;
// Access environment variables through import.meta.env
let settingsUrl = provider.settings.baseUrl;
if (settingsUrl && settingsUrl.trim().length === 0) {
settingsUrl = undefined;
}
const url = settingsUrl || import.meta.env[envVarName] || null; // Ensure baseUrl is used
console.log(`[Debug] Using URL for ${provider.name}:`, url, `(from ${envVarName})`);
const status = await checkProviderStatus(url, provider.name);
return {
...status,
enabled: provider.settings.enabled ?? false,
};
}),
);
setActiveProviders(statuses);
} catch (error) {
console.error('[Debug] Failed to update provider statuses:', error);
}
};
useEffect(() => {
updateProviderStatuses();
const interval = setInterval(updateProviderStatuses, 30000);
return () => clearInterval(interval);
}, [providers]);
const handleCheckForUpdate = useCallback(async () => {
if (isCheckingUpdate) {
return;
}
try {
setIsCheckingUpdate(true);
setUpdateMessage('Checking for updates...');
const branchToCheck = isLatestBranch ? 'main' : 'stable';
console.log(`[Debug] Checking for updates against ${branchToCheck} branch`);
const latestCommitResp = await GITHUB_URLS.commitJson(branchToCheck);
const remoteCommitHash = latestCommitResp.commit;
const currentCommitHash = versionHash;
if (remoteCommitHash !== currentCommitHash) {
setUpdateMessage(
`Update available from ${branchToCheck} branch!\n` +
`Current: ${currentCommitHash.slice(0, 7)}\n` +
`Latest: ${remoteCommitHash.slice(0, 7)}`,
);
} else {
setUpdateMessage(`You are on the latest version from the ${branchToCheck} branch`);
}
} catch (error) {
setUpdateMessage('Failed to check for updates');
console.error('[Debug] Failed to check for updates:', error);
} finally {
setIsCheckingUpdate(false);
}
}, [isCheckingUpdate, isLatestBranch]);
const handleCopyToClipboard = useCallback(() => {
const debugInfo = {
System: systemInfo,
Providers: activeProviders.map((provider) => ({
name: provider.name,
enabled: provider.enabled,
isLocal: provider.isLocal,
running: provider.isRunning,
error: provider.error,
lastChecked: provider.lastChecked,
responseTime: provider.responseTime,
url: provider.url,
})),
Version: {
hash: versionHash.slice(0, 7),
branch: isLatestBranch ? 'main' : 'stable',
},
Timestamp: new Date().toISOString(),
};
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
toast.success('Debug information copied to clipboard!');
});
}, [activeProviders, systemInfo, isLatestBranch]);
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
<div className="flex gap-2">
<button
onClick={handleCopyToClipboard}
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
>
Copy Debug Info
</button>
<button
onClick={handleCheckForUpdate}
disabled={isCheckingUpdate}
className={`bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200
${!isCheckingUpdate ? 'hover:bg-bolt-elements-button-primary-backgroundHover' : 'opacity-75 cursor-not-allowed'}
text-bolt-elements-button-primary-text`}
>
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
</button>
</div>
</div>
{updateMessage && (
<div
className={`bg-bolt-elements-surface rounded-lg p-3 ${
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-400' : ''
}`}
>
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
{updateMessage.includes('Update available') && (
<div className="mt-3 text-sm">
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
<ol className="list-decimal ml-4 mt-1 text-bolt-elements-textSecondary">
<li>
Pull the latest changes:{' '}
<code className="bg-bolt-elements-surface-hover px-1 rounded">git pull upstream main</code>
</li>
<li>
Install any new dependencies:{' '}
<code className="bg-bolt-elements-surface-hover px-1 rounded">pnpm install</code>
</li>
<li>Restart the application</li>
</ol>
</div>
)}
</div>
)}
<section className="space-y-4">
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">System Information</h4>
<div className="bg-bolt-elements-surface rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Device Type</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.deviceType}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Browser</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Display</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Connection</p>
<p className="text-sm font-medium flex items-center gap-2">
<span
className={`inline-block w-2 h-2 rounded-full ${systemInfo.online ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className={`${systemInfo.online ? 'text-green-600' : 'text-red-600'}`}>
{systemInfo.online ? 'Online' : 'Offline'}
</span>
</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Screen Resolution</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.screen}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Language</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Timezone</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">CPU Cores</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.cores}</p>
</div>
</div>
<div className="mt-3 pt-3 border-t border-bolt-elements-surface-hover">
<p className="text-xs text-bolt-elements-textSecondary">Version</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
{connitJson.commit.slice(0, 7)}
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
(v{versionTag || '0.0.1'}) - {isLatestBranch ? 'nightly' : 'stable'}
</span>
</p>
</div>
</div>
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">Local LLM Status</h4>
<div className="bg-bolt-elements-surface rounded-lg">
<div className="grid grid-cols-1 divide-y">
{activeProviders.map((provider) => (
<div key={provider.name} className="p-3 flex flex-col space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<div
className={`w-2 h-2 rounded-full ${
!provider.enabled ? 'bg-gray-300' : provider.isRunning ? 'bg-green-400' : 'bg-red-400'
}`}
/>
</div>
<div>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{provider.name}</p>
{provider.url && (
<p className="text-xs text-bolt-elements-textSecondary truncate max-w-[300px]">
{provider.url}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 text-xs rounded-full ${
provider.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</span>
{provider.enabled && (
<span
className={`px-2 py-0.5 text-xs rounded-full ${
provider.isRunning ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{provider.isRunning ? 'Running' : 'Not Running'}
</span>
)}
</div>
</div>
<div className="pl-5 flex flex-col space-y-1 text-xs">
{/* Status Details */}
<div className="flex flex-wrap gap-2">
<span className="text-bolt-elements-textSecondary">
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
</span>
{provider.responseTime && (
<span className="text-bolt-elements-textSecondary">
Response time: {Math.round(provider.responseTime)}ms
</span>
)}
</div>
{/* Error Message */}
{provider.error && (
<div className="mt-1 text-red-600 bg-red-50 rounded-md p-2">
<span className="font-medium">Error:</span> {provider.error}
</div>
)}
{/* Connection Info */}
{provider.url && (
<div className="text-bolt-elements-textSecondary">
<span className="font-medium">Endpoints checked:</span>
<ul className="list-disc list-inside pl-2 mt-1">
<li>{provider.url} (root)</li>
<li>{provider.url}/api/health</li>
<li>{provider.url}/v1/models</li>
</ul>
</div>
)}
</div>
</div>
))}
{activeProviders.length === 0 && (
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
)}
</div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -1,219 +0,0 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
import { Switch } from '~/components/ui/Switch';
import { logStore, type LogEntry } from '~/lib/stores/logs';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
export default function EventLogsTab() {
const {} = useSettings();
const showLogs = useStore(logStore.showLogs);
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [, forceUpdate] = useState({});
const filteredLogs = useMemo(() => {
const logs = logStore.getLogs();
return logs.filter((log) => {
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
const matchesSearch =
!searchQuery ||
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(log.details)?.toLowerCase()?.includes(searchQuery?.toLowerCase());
return matchesLevel && matchesSearch;
});
}, [logLevel, searchQuery]);
// Effect to initialize showLogs
useEffect(() => {
logStore.showLogs.set(true);
}, []);
useEffect(() => {
// System info logs
logStore.logSystem('Application initialized', {
version: process.env.NEXT_PUBLIC_APP_VERSION,
environment: process.env.NODE_ENV,
});
// Debug logs for system state
logStore.logDebug('System configuration loaded', {
runtime: 'Next.js',
features: ['AI Chat', 'Event Logging'],
});
// Warning logs for potential issues
logStore.logWarning('Resource usage threshold approaching', {
memoryUsage: '75%',
cpuLoad: '60%',
});
// Error logs with detailed context
logStore.logError('API connection failed', new Error('Connection timeout'), {
endpoint: '/api/chat',
retryCount: 3,
lastAttempt: new Date().toISOString(),
});
}, []);
useEffect(() => {
const container = document.querySelector('.logs-container');
if (container && autoScroll) {
container.scrollTop = container.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const handleClearLogs = useCallback(() => {
if (confirm('Are you sure you want to clear all logs?')) {
logStore.clearLogs();
toast.success('Logs cleared successfully');
forceUpdate({}); // Force a re-render after clearing logs
}
}, []);
const handleExportLogs = useCallback(() => {
try {
const logText = logStore
.getLogs()
.map(
(log) =>
`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}${
log.details ? '\nDetails: ' + JSON.stringify(log.details, null, 2) : ''
}`,
)
.join('\n\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-logs-${new Date().toISOString()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Logs exported successfully');
} catch (error) {
toast.error('Failed to export logs');
console.error('Export error:', error);
}
}, []);
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'info':
return 'text-blue-500';
case 'warning':
return 'text-yellow-500';
case 'error':
return 'text-red-500';
case 'debug':
return 'text-gray-500';
default:
return 'text-bolt-elements-textPrimary';
}
};
return (
<div className="p-4 h-full flex flex-col">
<div className="flex flex-col space-y-4 mb-4">
{/* Title and Toggles Row */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
</div>
</div>
</div>
{/* Controls Row */}
<div className="flex flex-wrap items-center gap-2">
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[20%] text-sm min-w-[100px]"
>
<option value="all">All</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
{showLogs && (
<div className="flex items-center gap-2 flex-nowrap">
<button
onClick={handleExportLogs}
className={classNames(
'bg-bolt-elements-button-primary-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'text-bolt-elements-button-primary-text',
)}
>
Export Logs
</button>
<button
onClick={handleClearLogs}
className={classNames(
'bg-bolt-elements-button-danger-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
'hover:bg-bolt-elements-button-danger-backgroundHover',
'text-bolt-elements-button-danger-text',
)}
>
Clear Logs
</button>
</div>
)}
</div>
</div>
<div className="bg-bolt-elements-bg-depth-1 rounded-lg p-4 h-[calc(100vh - 250px)] min-h-[400px] overflow-y-auto logs-container overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="text-center text-bolt-elements-textSecondary py-8">No logs found</div>
) : (
filteredLogs.map((log, index) => (
<div
key={index}
className="text-sm mb-3 font-mono border-b border-bolt-elements-borderColor pb-2 last:border-0"
>
<div className="flex items-start space-x-2 flex-wrap">
<span className={`font-bold ${getLevelColor(log.level)} whitespace-nowrap`}>
[{log.level.toUpperCase()}]
</span>
<span className="text-bolt-elements-textSecondary whitespace-nowrap">
{new Date(log.timestamp).toLocaleString()}
</span>
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
</div>
{log.details && (
<pre className="mt-2 text-xs text-bolt-elements-textSecondary overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(log.details, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { Switch } from '~/components/ui/Switch';
import { PromptLibrary } from '~/lib/common/prompt-library';
import { useSettings } from '~/lib/hooks/useSettings';
export default function FeaturesTab() {
const {
debug,
enableDebugMode,
isLocalModel,
enableLocalModels,
enableEventLogs,
isLatestBranch,
enableLatestBranch,
promptId,
setPromptId,
autoSelectTemplate,
setAutoSelectTemplate,
enableContextOptimization,
contextOptimizationEnabled,
} = useSettings();
const handleToggle = (enabled: boolean) => {
enableDebugMode(enabled);
enableEventLogs(enabled);
};
return (
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
<div className="mb-6">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Optional Features</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-bolt-elements-textPrimary">Debug Features</span>
<Switch className="ml-auto" checked={debug} onCheckedChange={handleToggle} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-bolt-elements-textPrimary">Use Main Branch</span>
<p className="text-xs text-bolt-elements-textTertiary">
Check for updates against the main branch instead of stable
</p>
</div>
<Switch className="ml-auto" checked={isLatestBranch} onCheckedChange={enableLatestBranch} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-bolt-elements-textPrimary">Auto Select Code Template</span>
<p className="text-xs text-bolt-elements-textTertiary">
Let Bolt select the best starter template for your project.
</p>
</div>
<Switch className="ml-auto" checked={autoSelectTemplate} onCheckedChange={setAutoSelectTemplate} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-bolt-elements-textPrimary">Use Context Optimization</span>
<p className="text-sm text-bolt-elements-textSecondary">
redact file contents form chat and puts the latest file contents on the system prompt
</p>
</div>
<Switch
className="ml-auto"
checked={contextOptimizationEnabled}
onCheckedChange={enableContextOptimization}
/>
</div>
</div>
</div>
<div className="mb-6 border-t border-bolt-elements-borderColor pt-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Experimental Features</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-10">
Disclaimer: Experimental features may be unstable and are subject to change.
</p>
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-bolt-elements-textPrimary">Experimental Providers</span>
<Switch className="ml-auto" checked={isLocalModel} onCheckedChange={enableLocalModels} />
</div>
<p className="text-xs text-bolt-elements-textTertiary mb-4">
Enable experimental providers such as Ollama, LMStudio, and OpenAILike.
</p>
</div>
<div className="flex items-start justify-between pt-4 mb-2 gap-2">
<div className="flex-1 max-w-[200px]">
<span className="text-bolt-elements-textPrimary">Prompt Library</span>
<p className="text-xs text-bolt-elements-textTertiary mb-4">
Choose a prompt from the library to use as the system prompt.
</p>
</div>
<select
value={promptId}
onChange={(e) => setPromptId(e.target.value)}
className="flex-1 p-2 ml-auto rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all text-sm min-w-[100px]"
>
{PromptLibrary.getList().map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View File

@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
// Import a default fallback icon
import { providerBaseUrlEnvKeys } from '~/utils/constants';
const DefaultIcon = '/icons/Default.svg'; // Adjust the path as necessary
export default function ProvidersTab() {
const { providers, updateProviderSettings, isLocalModel } = useSettings();
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
// Load base URLs from cookies
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
...value,
name: key,
}));
if (searchTerm && searchTerm.length > 0) {
newFilteredProviders = newFilteredProviders.filter((provider) =>
provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
if (!isLocalModel) {
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
}
newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
// Split providers into regular and URL-configurable
const regular = newFilteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
setFilteredProviders([...regular, ...urlConfigurable]);
}, [providers, searchTerm, isLocalModel]);
const renderProviderCard = (provider: IProviderConfig) => {
const envBaseUrlKey = providerBaseUrlEnvKeys[provider.name].baseUrlKey;
const envBaseUrl = envBaseUrlKey ? import.meta.env[envBaseUrlKey] : undefined;
const isUrlConfigurable = URL_CONFIGURABLE_PROVIDERS.includes(provider.name);
return (
<div
key={provider.name}
className="flex flex-col provider-item hover:bg-bolt-elements-bg-depth-3 p-4 rounded-lg border border-bolt-elements-borderColor"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<img
src={`/icons/${provider.name}.svg`}
onError={(e) => {
e.currentTarget.src = DefaultIcon;
}}
alt={`${provider.name} icon`}
className="w-6 h-6 dark:invert"
/>
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
</div>
<Switch
className="ml-auto"
checked={provider.settings.enabled}
onCheckedChange={(enabled) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
}
}}
/>
</div>
{isUrlConfigurable && provider.settings.enabled && (
<div className="mt-2">
{envBaseUrl && (
<label className="block text-xs text-bolt-elements-textSecondary text-green-300 mb-2">
Set On (.env) : {envBaseUrl}
</label>
)}
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
{envBaseUrl ? 'Override Base Url' : 'Base URL '}:{' '}
</label>
<input
type="text"
value={provider.settings.baseUrl || ''}
onChange={(e) => {
let newBaseUrl: string | undefined = e.target.value;
if (newBaseUrl && newBaseUrl.trim().length === 0) {
newBaseUrl = undefined;
}
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
});
}}
placeholder={`Enter ${provider.name} base URL`}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
)}
</div>
);
};
const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
return (
<div className="p-4">
<div className="flex mb-4">
<input
type="text"
placeholder="Search providers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
{/* Regular Providers Grid */}
<div className="grid grid-cols-2 gap-4 mb-8">{regularProviders.map(renderProviderCard)}</div>
{/* URL Configurable Providers Section */}
{urlConfigurableProviders.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-semibold mb-2 text-bolt-elements-textPrimary">Experimental Providers</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
These providers are experimental and allow you to run AI models locally or connect to your own
infrastructure. They require additional setup but offer more flexibility.
</p>
<div className="space-y-4">{urlConfigurableProviders.map(renderProviderCard)}</div>
</div>
)}
</div>
);
}

View File

@@ -24,47 +24,45 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
syncWithGlobalStore: isActiveChat, syncWithGlobalStore: isActiveChat,
}); });
const renderDescriptionForm = (
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
<input
type="text"
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
autoFocus
value={currentDescription}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
<button
type="submit"
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
onMouseDown={handleSubmit}
/>
</form>
);
return ( return (
<div <div
className={classNames( className={classNames(
'group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1', 'group rounded-lg text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50/80 dark:hover:bg-gray-800/30 overflow-hidden flex justify-between items-center px-3 py-2 transition-colors',
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat }, { 'text-gray-900 dark:text-white bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
)} )}
> >
{editing ? ( {editing ? (
renderDescriptionForm <form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
<input
type="text"
className="flex-1 bg-white dark:bg-gray-900 text-gray-900 dark:text-white rounded-md px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-800 focus:outline-none focus:ring-1 focus:ring-purple-500/50"
autoFocus
value={currentDescription}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
<button
type="submit"
className="i-ph:check h-4 w-4 text-gray-500 hover:text-purple-500 transition-colors"
onMouseDown={handleSubmit}
/>
</form>
) : ( ) : (
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block"> <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{currentDescription} <WithTooltip tooltip={currentDescription}>
<span className="truncate pr-24">{currentDescription}</span>
</WithTooltip>
<div <div
className={classNames( className={classNames(
'absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-22 group-hover:from-99%', 'absolute right-0 top-0 bottom-0 flex items-center bg-white dark:bg-gray-950 group-hover:bg-gray-50/80 dark:group-hover:bg-gray-800/30 px-2',
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat }, { 'bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
)} )}
> >
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-2.5 text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity">
<ChatActionButton <ChatActionButton
toolTipContent="Export chat" toolTipContent="Export"
icon="i-ph:download-simple" icon="i-ph:download-simple h-4 w-4"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
exportChat(item.id); exportChat(item.id);
@@ -72,14 +70,14 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
/> />
{onDuplicate && ( {onDuplicate && (
<ChatActionButton <ChatActionButton
toolTipContent="Duplicate chat" toolTipContent="Duplicate"
icon="i-ph:copy" icon="i-ph:copy h-4 w-4"
onClick={() => onDuplicate?.(item.id)} onClick={() => onDuplicate?.(item.id)}
/> />
)} )}
<ChatActionButton <ChatActionButton
toolTipContent="Rename chat" toolTipContent="Rename"
icon="i-ph:pencil-fill" icon="i-ph:pencil-fill h-4 w-4"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
toggleEditMode(); toggleEditMode();
@@ -87,9 +85,9 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
/> />
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<ChatActionButton <ChatActionButton
toolTipContent="Delete chat" toolTipContent="Delete"
icon="i-ph:trash" icon="i-ph:trash h-4 w-4"
className="[&&]:hover:text-bolt-elements-button-danger-text" className="hover:text-red-500"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
onDelete?.(event); onDelete?.(event);
@@ -121,11 +119,11 @@ const ChatActionButton = forwardRef(
ref: ForwardedRef<HTMLButtonElement>, ref: ForwardedRef<HTMLButtonElement>,
) => { ) => {
return ( return (
<WithTooltip tooltip={toolTipContent}> <WithTooltip tooltip={toolTipContent} position="bottom" sideOffset={4}>
<button <button
ref={ref} ref={ref}
type="button" type="button"
className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`} className={`text-gray-400 dark:text-gray-500 hover:text-purple-500 dark:hover:text-purple-400 transition-colors ${icon} ${className ? className : ''}`}
onClick={onClick} onClick={onClick}
/> />
</WithTooltip> </WithTooltip>

View File

@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { SettingsWindow } from '~/components/settings/SettingsWindow'; import { ControlPanel } from '~/components/@settings/core/ControlPanel';
import { SettingsButton } from '~/components/ui/SettingsButton'; import { SettingsButton } from '~/components/ui/SettingsButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence'; import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
@@ -11,12 +11,15 @@ import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem'; import { HistoryItem } from './HistoryItem';
import { binDates } from './date-binning'; import { binDates } from './date-binning';
import { useSearchFilter } from '~/lib/hooks/useSearchFilter'; import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
const menuVariants = { const menuVariants = {
closed: { closed: {
opacity: 0, opacity: 0,
visibility: 'hidden', visibility: 'hidden',
left: '-150px', left: '-340px',
transition: { transition: {
duration: 0.2, duration: 0.2,
ease: cubicEasingFn, ease: cubicEasingFn,
@@ -41,15 +44,18 @@ function CurrentDateTime() {
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setDateTime(new Date()); setDateTime(new Date());
}, 60000); // Update every minute }, 60000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
return ( return (
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor"> <div className="flex items-center gap-2 px-4 py-2 text-sm text-gray-600 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800/50">
<div className="h-4 w-4 i-ph:clock-thin" /> <div className="h-4 w-4 i-lucide:clock opacity-80" />
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} <div className="flex gap-2">
<span>{dateTime.toLocaleDateString()}</span>
<span>{dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div> </div>
); );
} }
@@ -61,6 +67,7 @@ export const Menu = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null); const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const profile = useStore(profileStore);
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({ const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
items: list, items: list,
@@ -111,6 +118,10 @@ export const Menu = () => {
const exitThreshold = 40; const exitThreshold = 40;
function onMouseMove(event: MouseEvent) { function onMouseMove(event: MouseEvent) {
if (isSettingsOpen) {
return;
}
if (event.pageX < enterThreshold) { if (event.pageX < enterThreshold) {
setOpen(true); setOpen(true);
} }
@@ -125,7 +136,7 @@ export const Menu = () => {
return () => { return () => {
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
}; };
}, []); }, [isSettingsOpen]);
const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => { const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault(); event.preventDefault();
@@ -137,96 +148,142 @@ export const Menu = () => {
loadEntries(); // Reload the list after duplication loadEntries(); // Reload the list after duplication
}; };
const handleSettingsClick = () => {
setIsSettingsOpen(true);
setOpen(false);
};
const handleSettingsClose = () => {
setIsSettingsOpen(false);
};
return ( return (
<motion.div <>
ref={menuRef} <motion.div
initial="closed" ref={menuRef}
animate={open ? 'open' : 'closed'} initial="closed"
variants={menuVariants} animate={open ? 'open' : 'closed'}
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm" variants={menuVariants}
> style={{ width: '340px' }}
<div className="h-[60px]" /> {/* Spacer for top margin */} className={classNames(
<CurrentDateTime /> 'flex selection-accent flex-col side-menu fixed top-0 h-full',
<div className="flex-1 flex flex-col h-full w-full overflow-hidden"> 'bg-white dark:bg-gray-950 border-r border-gray-100 dark:border-gray-800/50',
<div className="p-4 select-none"> 'shadow-sm text-sm',
<a isSettingsOpen ? 'z-40' : 'z-sidebar',
href="/" )}
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4" >
> <div className="h-12 flex items-center justify-between px-4 border-b border-gray-100 dark:border-gray-800/50 bg-gray-50/50 dark:bg-gray-900/50">
<span className="inline-block i-bolt:chat scale-110" /> <div className="text-gray-900 dark:text-white font-medium"></div>
Start new chat <div className="flex items-center gap-3">
</a> <span className="font-medium text-sm text-gray-900 dark:text-white truncate">
<div className="relative w-full"> {profile?.username || 'Guest User'}
<input </span>
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" <div className="flex items-center justify-center w-[32px] h-[32px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0">
type="search" {profile?.avatar ? (
placeholder="Search" <img
onChange={handleSearchChange} src={profile.avatar}
aria-label="Search chats" alt={profile?.username || 'User'}
/> className="w-full h-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="i-ph:user-fill text-lg" />
)}
</div>
</div> </div>
</div> </div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div> <CurrentDateTime />
<div className="flex-1 overflow-auto pl-4 pr-5 pb-5"> <div className="flex-1 flex flex-col h-full w-full overflow-hidden">
{filteredList.length === 0 && ( <div className="p-4 space-y-3">
<div className="pl-2 text-bolt-elements-textTertiary"> <a
{list.length === 0 ? 'No previous conversations' : 'No matches found'} href="/"
</div> className="flex gap-2 items-center bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-500/20 rounded-lg px-4 py-2 transition-colors"
)} >
<DialogRoot open={dialogContent !== null}> <span className="inline-block i-lucide:message-square h-4 w-4" />
{binDates(filteredList).map(({ category, items }) => ( <span className="text-sm font-medium">Start new chat</span>
<div key={category} className="mt-4 first:mt-0 space-y-1"> </a>
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1"> <div className="relative w-full">
{category} <div className="absolute left-3 top-1/2 -translate-y-1/2">
</div> <span className="i-lucide:search h-4 w-4 text-gray-400 dark:text-gray-500" />
{items.map((item) => (
<HistoryItem
key={item.id}
item={item}
exportChat={exportChat}
onDelete={(event) => handleDeleteClick(event, item)}
onDuplicate={() => handleDuplicate(item.id)}
/>
))}
</div> </div>
))} <input
<Dialog onBackdrop={closeDialog} onClose={closeDialog}> className="w-full bg-gray-50 dark:bg-gray-900 relative pl-9 pr-3 py-2 rounded-lg focus:outline-none focus:ring-1 focus:ring-purple-500/50 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-500 border border-gray-200 dark:border-gray-800"
{dialogContent?.type === 'delete' && ( type="search"
<> placeholder="Search chats..."
<DialogTitle>Delete Chat?</DialogTitle> onChange={handleSearchChange}
<DialogDescription asChild> aria-label="Search chats"
<div> />
<p> </div>
You are about to delete <strong>{dialogContent.item.description}</strong>. </div>
</p> <div className="text-gray-600 dark:text-gray-400 text-sm font-medium px-4 py-2">Your Chats</div>
<p className="mt-1">Are you sure you want to delete this chat?</p> <div className="flex-1 overflow-auto px-3 pb-3">
</div> {filteredList.length === 0 && (
</DialogDescription> <div className="px-4 text-gray-500 dark:text-gray-400 text-sm">
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end"> {list.length === 0 ? 'No previous conversations' : 'No matches found'}
<DialogButton type="secondary" onClick={closeDialog}> </div>
Cancel )}
</DialogButton> <DialogRoot open={dialogContent !== null}>
<DialogButton {binDates(filteredList).map(({ category, items }) => (
type="danger" <div key={category} className="mt-2 first:mt-0 space-y-1">
onClick={(event) => { <div className="text-xs font-medium text-gray-500 dark:text-gray-400 sticky top-0 z-1 bg-white dark:bg-gray-950 px-4 py-1">
deleteItem(event, dialogContent.item); {category}
closeDialog();
}}
>
Delete
</DialogButton>
</div> </div>
</> <div className="space-y-0.5 pr-1">
)} {items.map((item) => (
</Dialog> <HistoryItem
</DialogRoot> key={item.id}
item={item}
exportChat={exportChat}
onDelete={(event) => handleDeleteClick(event, item)}
onDuplicate={() => handleDuplicate(item.id)}
/>
))}
</div>
</div>
))}
<Dialog onBackdrop={closeDialog} onClose={closeDialog}>
{dialogContent?.type === 'delete' && (
<>
<div className="p-6 bg-white dark:bg-gray-950">
<DialogTitle className="text-gray-900 dark:text-white">Delete Chat?</DialogTitle>
<DialogDescription className="mt-2 text-gray-600 dark:text-gray-400">
<p>
You are about to delete{' '}
<span className="font-medium text-gray-900 dark:text-white">
{dialogContent.item.description}
</span>
</p>
<p className="mt-2">Are you sure you want to delete this chat?</p>
</DialogDescription>
</div>
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800">
<DialogButton type="secondary" onClick={closeDialog}>
Cancel
</DialogButton>
<DialogButton
type="danger"
onClick={(event) => {
deleteItem(event, dialogContent.item);
closeDialog();
}}
>
Delete
</DialogButton>
</div>
</>
)}
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-800 px-4 py-3">
<SettingsButton onClick={handleSettingsClick} />
<ThemeSwitch />
</div>
</div> </div>
<div className="flex items-center justify-between border-t border-bolt-elements-borderColor p-4"> </motion.div>
<SettingsButton onClick={() => setIsSettingsOpen(true)} />
<ThemeSwitch /> <ControlPanel open={isSettingsOpen} onClose={handleSettingsClose} />
</div> </>
</div>
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</motion.div>
); );
}; };

View File

@@ -39,21 +39,21 @@ function dateCategory(date: Date) {
} }
if (isThisWeek(date)) { if (isThisWeek(date)) {
// e.g., "Monday" // e.g., "Mon" instead of "Monday"
return format(date, 'eeee'); return format(date, 'EEE');
} }
const thirtyDaysAgo = subDays(new Date(), 30); const thirtyDaysAgo = subDays(new Date(), 30);
if (isAfter(date, thirtyDaysAgo)) { if (isAfter(date, thirtyDaysAgo)) {
return 'Last 30 Days'; return 'Past 30 Days';
} }
if (isThisYear(date)) { if (isThisYear(date)) {
// e.g., "July" // e.g., "Jan" instead of "January"
return format(date, 'MMMM'); return format(date, 'LLL');
} }
// e.g., "July 2023" // e.g., "Jan 2023" instead of "January 2023"
return format(date, 'MMMM yyyy'); return format(date, 'LLL yyyy');
} }

View File

@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { classNames } from '~/utils/classNames';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background/80',
secondary:
'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
outline: 'text-bolt-elements-textPrimary',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { classNames } from '~/utils/classNames';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-bolt-elements-borderColor disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
destructive: 'bg-red-500 text-white hover:bg-red-600',
outline:
'border border-input bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary',
secondary:
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',
link: 'text-bolt-elements-textPrimary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
_asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, _asChild = false, ...props }, ref) => {
return <button className={classNames(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import { forwardRef } from 'react';
import { classNames } from '~/utils/classNames';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
const Card = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={classNames(
'rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary shadow-sm',
className,
)}
{...props}
/>
);
});
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return <div ref={ref} className={classNames('flex flex-col space-y-1.5 p-6', className)} {...props} />;
});
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
return (
<h3
ref={ref}
className={classNames('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
},
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
return <p ref={ref} className={classNames('text-sm text-bolt-elements-textSecondary', className)} {...props} />;
},
);
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return <div ref={ref} className={classNames('p-6 pt-0', className)} {...props} />;
});
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={classNames('flex items-center p-6 pt-0', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,9 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
const CollapsibleContent = CollapsiblePrimitive.Content;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -7,6 +7,52 @@ import { IconButton } from './IconButton';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog'; export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
interface DialogButtonProps {
type: 'primary' | 'secondary' | 'danger';
children: ReactNode;
onClick?: (event: React.MouseEvent) => void;
disabled?: boolean;
}
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
return (
<button
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors', {
'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600': type === 'primary',
'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100':
type === 'secondary',
'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger',
})}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
});
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return (
<RadixDialog.Title
className={classNames('text-lg font-medium text-bolt-elements-textPrimary', 'flex items-center gap-2', className)}
{...props}
>
{children}
</RadixDialog.Title>
);
});
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('text-sm text-bolt-elements-textSecondary', 'mt-1', className)}
{...props}
>
{children}
</RadixDialog.Description>
);
});
const transition = { const transition = {
duration: 0.15, duration: 0.15,
ease: cubicEasingFn, ease: cubicEasingFn,
@@ -40,81 +86,39 @@ export const dialogVariants = {
}, },
} satisfies Variants; } satisfies Variants;
interface DialogButtonProps {
type: 'primary' | 'secondary' | 'danger';
children: ReactNode;
onClick?: (event: React.UIEvent) => void;
}
export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
return (
<button
className={classNames(
'inline-flex h-[35px] items-center justify-center rounded-lg px-4 text-sm leading-none focus:outline-none',
{
'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text hover:bg-bolt-elements-button-primary-backgroundHover':
type === 'primary',
'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
type === 'secondary',
'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
type === 'danger',
},
)}
onClick={onClick}
>
{children}
</button>
);
});
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return (
<RadixDialog.Title
className={classNames(
'px-5 py-4 flex items-center justify-between border-b border-bolt-elements-borderColor text-lg font-semibold leading-6 text-bolt-elements-textPrimary',
className,
)}
{...props}
>
{children}
</RadixDialog.Title>
);
});
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('px-5 py-4 text-bolt-elements-textPrimary text-md', className)}
{...props}
>
{children}
</RadixDialog.Description>
);
});
interface DialogProps { interface DialogProps {
children: ReactNode | ReactNode[]; children: ReactNode;
className?: string; className?: string;
onBackdrop?: (event: React.UIEvent) => void; showCloseButton?: boolean;
onClose?: (event: React.UIEvent) => void; onClose?: () => void;
onBackdrop?: () => void;
} }
export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => { export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => {
return ( return (
<RadixDialog.Portal> <RadixDialog.Portal>
<RadixDialog.Overlay onClick={onBackdrop} asChild> <RadixDialog.Overlay asChild>
<motion.div <motion.div
className="bg-black/50 fixed inset-0 z-max" className={classNames(
'fixed inset-0 z-[9999]',
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
'backdrop-blur-[2px]',
)}
initial="closed" initial="closed"
animate="open" animate="open"
exit="closed" exit="closed"
variants={dialogBackdropVariants} variants={dialogBackdropVariants}
onClick={onBackdrop}
/> />
</RadixDialog.Overlay> </RadixDialog.Overlay>
<RadixDialog.Content asChild> <RadixDialog.Content asChild>
<motion.div <motion.div
className={classNames( className={classNames(
'fixed top-[50%] left-[50%] z-max max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-2 shadow-lg focus:outline-none overflow-hidden', 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-lg shadow-lg',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'z-[9999] w-[520px]',
className, className,
)} )}
initial="closed" initial="closed"
@@ -122,10 +126,17 @@ export const Dialog = memo(({ className, children, onBackdrop, onClose }: Dialog
exit="closed" exit="closed"
variants={dialogVariants} variants={dialogVariants}
> >
{children} <div className="flex flex-col">
<RadixDialog.Close asChild onClick={onClose}> {children}
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" /> {showCloseButton && (
</RadixDialog.Close> <RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
/>
</RadixDialog.Close>
)}
</div>
</motion.div> </motion.div>
</RadixDialog.Content> </RadixDialog.Content>
</RadixDialog.Portal> </RadixDialog.Portal>

View File

@@ -0,0 +1,63 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { type ReactNode } from 'react';
import { classNames } from '~/utils/classNames';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
align?: 'start' | 'center' | 'end';
sideOffset?: number;
}
interface DropdownItemProps {
children: ReactNode;
onSelect?: () => void;
className?: string;
}
export const DropdownItem = ({ children, onSelect, className }: DropdownItemProps) => (
<DropdownMenu.Item
className={classNames(
'relative flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'text-bolt-elements-textPrimary hover:text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-3',
'transition-colors cursor-pointer',
'outline-none',
className,
)}
onSelect={onSelect}
>
{children}
</DropdownMenu.Item>
);
export const DropdownSeparator = () => <DropdownMenu.Separator className="h-px bg-bolt-elements-borderColor my-1" />;
export const Dropdown = ({ trigger, children, align = 'end', sideOffset = 5 }: DropdownProps) => {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[220px] rounded-lg p-2',
'bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'shadow-lg',
'animate-in fade-in-80 zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'z-[1000]',
)}
sideOffset={sideOffset}
align={align}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,22 @@
import { forwardRef } from 'react';
import { classNames } from '~/utils/classNames';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={classNames(
'flex h-10 w-full rounded-md border border-bolt-elements-border bg-bolt-elements-background px-3 py-2 text-sm ring-offset-bolt-elements-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-bolt-elements-textSecondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { classNames } from '~/utils/classNames';
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={classNames(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -1,13 +1,31 @@
export const LoadingOverlay = ({ message = 'Loading...' }) => { export const LoadingOverlay = ({
message = 'Loading...',
progress,
progressText,
}: {
message?: string;
progress?: number;
progressText?: string;
}) => {
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50 backdrop-blur-sm"> <div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50 backdrop-blur-sm">
{/* Loading content */}
<div className="relative flex flex-col items-center gap-4 p-8 rounded-lg bg-bolt-elements-background-depth-2 shadow-lg"> <div className="relative flex flex-col items-center gap-4 p-8 rounded-lg bg-bolt-elements-background-depth-2 shadow-lg">
<div <div
className={'i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress'} className={'i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress'}
style={{ fontSize: '2rem' }} style={{ fontSize: '2rem' }}
></div> ></div>
<p className="text-lg text-bolt-elements-textTertiary">{message}</p> <p className="text-lg text-bolt-elements-textTertiary">{message}</p>
{progress !== undefined && (
<div className="w-64 flex flex-col gap-2">
<div className="w-full h-2 bg-bolt-elements-background-depth-1 rounded-full overflow-hidden">
<div
className="h-full bg-bolt-elements-loader-progress transition-all duration-300 ease-out rounded-full"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
{progressText && <p className="text-sm text-bolt-elements-textTertiary text-center">{progressText}</p>}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,29 @@
import * as Popover from '@radix-ui/react-popover';
import type { PropsWithChildren, ReactNode } from 'react';
export default ({
children,
trigger,
side,
align,
}: PropsWithChildren<{
trigger: ReactNode;
side: 'top' | 'right' | 'bottom' | 'left' | undefined;
align: 'center' | 'start' | 'end' | undefined;
}>) => (
<Popover.Root>
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Anchor />
<Popover.Portal>
<Popover.Content
sideOffset={10}
side={side}
align={align}
className="bg-bolt-elements-background-depth-2 text-bolt-elements-item-contentAccent p-2 rounded-md shadow-xl z-workbench"
>
{children}
<Popover.Arrow className="bg-bolt-elements-item-background-depth-2" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);

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