308 Commits

Author SHA1 Message Date
github-actions[bot]
b7c8677cb8 chore: release version 0.0.7 2025-02-28 20:13:14 +00:00
Anirban Kar
6c5d094ed7 ci: fixed bug with release notes on github release action (#1401) #release 2025-03-01 01:42:41 +05:30
Anirban Kar
3c28e8ad88 fix: fix enhance prompt to stop implementing full project instead of enhancing (#1383) #release
* fix: enhance prompt fix

* fix: added error capture on api error

* fix: replaced error with log for wrong files selected by bolt
2025-03-01 01:34:35 +05:30
Anirban Kar
b98485d99f feat: make user made changes persistent after reload (#1387)
* feat: save user made changes persistent

* fix: remove artifact from user message on the UI

* fix: message Id generation fix
2025-02-27 13:34:57 +05:30
Phr33d0m
a33a1268c3 Fix broken astro project git clone (#1352) 2025-02-26 22:24:40 +05:30
Anirban Kar
dc20bbc81f feat: added anthropic dynamic models (#1374) 2025-02-26 22:04:46 +05:30
KevIsDev
5d1816be9d Merge pull request #1367 from Toddyclipsgg/diff-view-v2
feat: diff view v3
2025-02-26 10:37:21 +00:00
Toddyclipsgg
a8d8b7b8c7 Merge branch 'main' into diff-view-v2 2025-02-25 19:29:59 -03:00
KevIsDev
5d9bb00ee2 Merge pull request #1376 from xKevIsDev/main
feat: netlify one click deployment
2025-02-25 22:12:10 +00:00
KevIsDev
23c22c5c12 fix: show netlify deployed link
netlify deploy button to be disabled on streaming and show link icon when deployed
2025-02-25 19:02:03 +00:00
KevIsDev
002f1bc5dc Merge branch 'stackblitz-labs:main' into main 2025-02-25 15:07:08 +00:00
KevIsDev
19137c934b add: various improvements to connections
- improved organisation of connections (collapsibles)
- improved deploy button
- improved unique chat deployments
2025-02-25 00:41:44 +00:00
KevIsDev
96a0b2a066 add: connection improvements
Improve connections visually and functionality
2025-02-25 00:39:39 +00:00
Toddyclipsgg
1098188427 feat: Improve DiffView theme and color consistency
- Added dark/light theme support for syntax highlighting
- Enhanced color styles for added/removed lines and characters
- Integrated theme store to dynamically adjust syntax highlighter theme
- Refined color contrast for better readability across themes
2025-02-24 20:06:15 -03:00
Leex
67c4051f82 Update docker.yaml 2025-02-24 23:27:42 +01:00
KevIsDev
4da13d1edc feat: add netlify one-click deployment 2025-02-24 17:24:32 +00:00
KevIsDev
2a8472ed17 feat: add one-click netlify deployment 2025-02-24 17:24:00 +00:00
Toddyclipsgg
afb82e2cf9 chore: Remove unnecessary history directory creation in GitHub Actions 2025-02-23 20:46:18 -03:00
Toddyclipsgg
056a446f0f chore: Remove unused dependencies and clean up imports
- Removed lucide-react and next-themes from package dependencies
- Simplified import in workbench store for path and file-saver
- Removed unnecessary module definition in Vite config
2025-02-23 20:00:17 -03:00
Toddyclipsgg
36872ee6a0 refactor: Enhance Diff View with advanced line and character-level change detection
- Improved diff algorithm to detect more granular line and character-level changes
- Added support for character-level highlighting in diff view
- Simplified diff view mode by removing side-by-side option
- Updated component rendering to support more detailed change visualization
- Optimized line change detection with improved matching strategy
2025-02-23 19:34:27 -03:00
Leex
7dda7938d4 Update docker.yaml 2025-02-23 22:52:23 +01:00
Leex
52970812cb Update Dockerfile 2025-02-23 22:28:24 +01:00
Leex
8e790d08e2 Update Dockerfile - Test Bugfix Dockerpipeline
the npm install -g corepack@latest is supposed to make problems with the main docker build
2025-02-23 22:21:55 +01:00
Leex
f0ea22ec63 Update docker.yaml (stable/main deployment)
Exteneded the workflow/action to also deploy a stable release container
2025-02-23 18:45:56 +01:00
Toddyclipsgg
b3ec53fa42 Merge branch 'stackblitz-labs:main' into diff-view-v2 2025-02-23 07:56:06 -03:00
Toddyclipsgg
ab6f5328b4 feat: Add Diff View and File History Tracking
- Implemented a new Diff View in the Workbench to visualize file changes
- Added file history tracking with detailed change information
- Enhanced FileTree and FileModifiedDropdown to show line additions and deletions
- Integrated file history saving and retrieval in ActionRunner
- Updated Workbench view types to include 'diff' option
- Added support for inline and side-by-side diff view modes
2025-02-23 07:55:38 -03: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
github-actions[bot]
608d9338a8 chore: release version 0.0.5 2024-12-31 22:28:11 +00:00
Anirban Kar
67d984c52c fix: hotfix auto select starter template works without github token #release (#959)
* fix: hotfix starter template fix, updated header link to use navigate

* template auth fix

* updated changelog script
2025-01-01 03:57:55 +05:30
github-actions[bot]
1cf83454ed chore: release version 0.0.4 2024-12-31 17:17:50 +00:00
Anirban Kar
6494f5ac2e fix: updated logger and model caching minor bugfix #release (#895)
* fix: updated logger and model caching

* usage token stream issue fix

* minor changes

* updated starter template change to fix the app title

* starter template bigfix

* fixed hydretion errors and raw logs

* removed raw log

* made auto select template false by default

* more cleaner logs and updated logic to call dynamicModels only if not found in static models

* updated starter template instructions

* browser console log improved for firefox

* provider icons fix icons
2024-12-31 22:47:32 +05:30
Anirban Kar
389eedcac4 fix: better model loading ui feedback and model list update (#954)
* fix: better model loading feedback and model list update

* added load on providersettings  update
2024-12-31 19:22:46 +05:30
Eduard Ruzga
55cfd5d4ee fix: refresh model list after api key changes (#944) 2024-12-30 23:35:08 +05:30
Arsalaan Ahmed
e00264236e feat: added hyperbolic llm models (#943)
* Added Hyperbolic Models

* Fix: Fixed problem in connecting with hyperbolic models

* added dynamic models for hyperbolic

* removed logs
2024-12-30 23:26:33 +05:30
Eduard Ruzga
93c2a6e81d Merge pull request #939 from juanmcampos/fix-project-import-button
fix: import folder filtering
2024-12-30 19:39:27 +02:00
Eduard Ruzga
4c81cb02e1 fix: add defaults for LMStudio to work out of the box (#928) 2024-12-30 17:50:13 +05:30
Juan Manuel Campos Olvera
6e3b5dcbd7 fix: import folder filtering 2024-12-29 20:21:49 -06:00
Anirban Kar
4c81e154a0 feat: added Automatic Code Template Detection And Import (#867)
* initial setup

* updated template list

* added optional switch to control this feature

* removed some logs
2024-12-29 15:52:37 +05:30
Anirban Kar
3a36a4469a feat: redact file contents from chat and put latest files into system prompt (#904) 2024-12-29 15:36:31 +05:30
Eduard Ruzga
aecd9b1b40 fix: detect and remove markdown block syntax that llms sometimes hallucinate for file actions (#886)
* Clean out markdown syntax

* Remove identation removal

* Improve for streaming
2024-12-29 15:23:20 +05:30
Anirban Kar
4844db82fa ci: improved change-log generation script and cleaner release ci action (#896)
* build: improved-changelog

* added a better change log script

* improved changelog script

* improved change log script
2024-12-26 00:58:42 +05:30
Eduard Ruzga
7eee0386ff feat: catch errors from web container preview and show in actionable alert so user can send them to AI for fixing (#856)
* Catch errors from web container

* Show fix error popup on errors in preview

* Remove unneeded action type

* PR comments

* Cleanup urls in stacktrace

---------

Co-authored-by: Anirban Kar <thecodacus@gmail.com>
2024-12-25 03:05:44 +05:30
Anirban Kar
fc4f89f806 feat: add Starter template menu in homepage (#884)
* added icons and component

* updated unocss to add dynamic icons

* removed temp logs

* updated readme
2024-12-24 22:37:28 +05:30
Anirban Kar
8185fd56ca fix: check for updates does not look for commit.json now (#861) 2024-12-24 13:34:46 +05:30
Alex Parker
d673206952 docs: updated env.example of OLLAMA & LMSTUDIO base url (#877)
* correct OLLAMA_API_BASE_URL

* correct OLLAMA_API_BASE_URL

* correct OLLAMA_API_BASE_URL
2024-12-24 13:32:04 +05:30
Eduard Ruzga
79e7e75228 Merge pull request #797 from thecodacus/terminal-error-detection
feat: enhanced Terminal Error Handling and Alert System
2024-12-23 14:25:39 +02:00
Dustin Loring
1f938fca40 feat; data tab added to the settings
added a data tab to the settings
removed chat history
2024-12-22 06:20:14 -05:00
Anirban Kar
8b58c7a0fb fix: ollama provider module base url hotfix for docker (#863)
* fix: ollama base url hotfix

* cleanup logic
2024-12-22 01:25:48 +05:30
Anirban Kar
13b208df46 better osc code cleanup 2024-12-22 01:01:06 +05:30
Anirban Kar
d8943f9474 fix: provider menu dropdown fix (ghost providers) (#862) 2024-12-21 23:31:19 +05:30
Anirban Kar
47471e05d9 updated read me 2024-12-21 18:22:17 +05:30
Anirban Kar
28f0c3629d ignored alert on project reload 2024-12-21 17:44:18 +05:30
Anirban Kar
975e7d83fd Merge branch 'main' into terminal-error-detection 2024-12-21 16:14:46 +05:30
Anirban Kar
7295352a98 refactor: refactored LLM Providers: Adapting Modular Approach (#832)
* refactor: Refactoring Providers to have providers as modules

* updated package and lock file

* added grok model back

* updated registry system
2024-12-21 11:45:17 +05:30
Anirban Kar
63abf52000 fix hotfix for version metadata issue (#853) 2024-12-21 02:24:57 +05:30
Anirban Kar
df6925ac07 fix: add Message Processing Throttling to Prevent Browser Crashes (#848) 2024-12-21 02:13:29 +05:30
Anirban Kar
80d9800aa0 docs: updated download link (#850) 2024-12-21 01:52:45 +05:30
Anirban Kar
dbd24142ae ci: updated the docs ci to only trigger if any files changed in the docs folder (#849) 2024-12-21 01:09:44 +05:30
Cole Medin
7afd654210 Merge pull request #845 from dustinwloring1988/docs/updated-docs
docs: updated Docs
2024-12-20 13:37:16 -06:00
Anirban Kar
d8a277f0ac refactor: updated vite config to inject add version metadata into the app on build (#841)
* refactor: removes commit.json and used vite.config to load these variables

* updated precommit hook

* updated the pre start script

* updated the workflows
2024-12-21 01:03:28 +05:30
Dustin Loring
6145c603a1 Merge branch 'main' into docs/updated-docs 2024-12-20 14:21:50 -05:00
github-actions[bot]
56edbc4699 chore: update commit hash to ab5cde30a1 2024-12-20 19:17:25 +00:00
Dustin Loring
ab5cde30a1 docs: setup updated
docs: updated setup guide to have more detailed instructions
2024-12-20 14:17:12 -05:00
Dustin Loring
f2662c1c71 Merge branch 'main' into docs-setup-updated 2024-12-20 14:15:46 -05:00
Dustin Loring
ed5bad36ba docs: added info on both ways to set api keys 2024-12-20 10:02:06 -05:00
Dustin Loring
0c07bc9105 docs: added info on the Releases Page
Added the option to download from the Releases Page instead of git clone in the README
2024-12-20 09:50:47 -05:00
Dustin Loring
58db67683e docs: added info on updating using docker
Added docker-compose --profile development up --build  to the update section
2024-12-20 09:42:54 -05:00
Dustin Loring
7e70dc5d8e docs: updated Contributing
updated Contributing in the docs
updated Contributing and FAQ in the GitHub part as well
2024-12-20 09:37:34 -05:00
github-actions[bot]
2638c1a704 chore: update commit hash to 636f87f568 2024-12-19 20:17:35 +00:00
Dustin Loring
636f87f568 docs: faq style change, toc added to index
docs: updated style in FAQ
added: TOC to the index page
2024-12-19 15:17:21 -05:00
Dustin Loring
0733813af8 docs: updated style in faq
updated style in FAQ docs to be an accordion like style
added a TOC to the index page in the docs
2024-12-19 15:12:21 -05:00
github-actions[bot]
01599caf38 chore: update commit hash to 75ec49beb8 2024-12-19 18:08:12 +00:00
Dustin Loring
75ec49beb8 feat: providers list is now 2 columns
The providers list in the settings modal is now 2 columns allowing you to see more at once.
2024-12-19 13:07:58 -05:00
Dustin Loring
bbd9a17d3b Update ProvidersTab.tsx 2024-12-19 13:04:26 -05:00
Dustin Loring
a7551ecfcc Update ProvidersTab.tsx 2024-12-19 12:57:59 -05:00
Dustin Loring
db769e0c90 Merge branch 'main' into feat/improved-providers-list 2024-12-19 12:43:51 -05:00
github-actions[bot]
d4400a5704 chore: update commit hash to a53b10ff39 2024-12-19 15:53:04 +00:00
Anirban Kar
a53b10ff39 Merge pull request #800 from gokulkgm/xai-update
feat: add xAI grok-2-1212 model
2024-12-19 21:22:52 +05:30
github-actions[bot]
7e344aaa5c chore: update commit hash to 381d490f1f 2024-12-19 12:33:56 +00:00
Anirban Kar
381d490f1f Merge pull request #714 from kris1803/fix/described_by
fix: fixed console error for SettingsWIndow & Removed ts-nocheck
2024-12-19 18:03:46 +05:30
Anirban Kar
3e2fc32a46 Merge branch 'main' into fix/described_by 2024-12-19 18:02:43 +05:30
github-actions[bot]
1d716c0925 chore: update commit hash to de64007697 2024-12-19 11:25:23 +00:00
Dustin Loring
de64007697 docs: toc for readme
docs: added a TOC for the README
2024-12-19 06:25:11 -05:00
github-actions[bot]
906cc38e79 chore: update commit hash to 95dbcf1eb7 2024-12-19 09:20:48 +00:00
Anirban Kar
95dbcf1eb7 Merge pull request #789 from thecodacus/bolt-shell-race-condition
fix: added wait till terminal prompt for bolt shell execution
2024-12-19 14:50:37 +05:30
Anirban Kar
69c58c1410 Merge branch 'main' into bolt-shell-race-condition 2024-12-19 14:50:24 +05:30
Anirban Kar
602f65a4dc updated some text 2024-12-18 23:41:59 +05:30
Anirban Kar
1398344c11 docs: updated setup guide to have more detailed instructions 2024-12-18 23:34:41 +05:30
github-actions[bot]
296e5d9f77 chore: update commit hash to 50e6778784 2024-12-18 16:28:44 +00:00
Anirban Kar
50e6778784 Merge pull request #816 from thecodacus/env-file-fix
fix: fixed model not loading/working, even after baseUrl set in .env file
2024-12-18 21:58:32 +05:30
Anirban Kar
3fba4f0b61 Merge branch 'main' into env-file-fix 2024-12-18 21:57:02 +05:30
Anirban Kar
b892d708ec removed logs and openAI fix 2024-12-18 21:55:17 +05:30
Anirban Kar
d37c3736d5 removed logs 2024-12-18 21:34:18 +05:30
Anirban Kar
6975083fb9 stopped apikeys set to envfile being exposed to UI 2024-12-18 21:31:50 +05:30
Anirban Kar
620d13ad8a updated title header 2024-12-18 21:29:54 +05:30
Anirban Kar
b25b8b98f9 updated styling wordings and animations icons 2024-12-18 21:09:36 +05:30
github-actions[bot]
90c9c9c760 chore: update commit hash to 6458211bed 2024-12-18 15:12:36 +00:00
Cole Medin
6458211bed Merge pull request #822 from stackblitz-labs/documentation-enhancements
chore: a few documentation enhancements for main README and FAQs
2024-12-18 09:12:24 -06:00
Anirban Kar
3d8abee511 Merge branch 'main' into terminal-error-detection 2024-12-18 20:19:59 +05:30
Anirban Kar
26a3bcf9b6 Merge branch 'env-file-fix-old' into env-file-fix 2024-12-18 20:06:29 +05:30
Anirban Kar
283eb22ae5 added indicator on settings menu 2024-12-18 20:04:43 +05:30
Dustin Loring
7458b22780 fixed API Key import 2024-12-18 09:06:58 -05:00
Cole Medin
b3b4d74824 chore: a few documentation enhancements for main README and FAQs 2024-12-18 07:01:58 -06:00
github-actions[bot]
a1e902fe79 chore: update commit hash to a9309161e9 2024-12-18 12:31:51 +00:00
Cole Medin
a9309161e9 Merge pull request #817 from dustinwloring1988/docs/more-beginner-friendly
docs: simplified setup
2024-12-18 06:31:33 -06:00
Dustin Loring
278bd242b1 docs: simplified setup
Removed the section about environment variables. In docker removed the production build. Both of these can still be found in the docs.
2024-12-18 06:35:33 -05:00
Anirban Kar
e74d6cafb5 Merge branch 'main' into env-file-fix 2024-12-18 16:41:49 +05:30
Anirban Kar
62ebfe51a6 fix: .env file baseUrl Issue 2024-12-18 16:34:18 +05:30
Dustin Loring
ba4e78809c feat: improved providers list style
made the list 2 columns wide and separate out the experimental providers
2024-12-17 17:40:16 -05:00
github-actions[bot]
6003c165ef chore: update commit hash to e064803955 2024-12-17 22:12:17 +00:00
Dustin Loring
e064803955 fix: chat title character restriction
added: characters to valid list for chat title
2024-12-17 17:12:01 -05:00
Dustin Loring
1a0ede6774 Update DataTab.tsx 2024-12-17 16:57:16 -05:00
Dustin Loring
a9b15e56c9 feat: Data Tab
Removed Chat History Tab
Added Data Tab
Data tab can export and delete chat history, import API keys, import and export settings
2024-12-17 16:44:56 -05:00
Anirban Kar
fd193ee7d7 Merge branch 'main' into terminal-error-detection 2024-12-18 02:36:38 +05:30
github-actions[bot]
fce8999f27 chore: release version 0.0.3 2024-12-17 21:00:04 +00:00
Anirban Kar
eb6d435356 Merge pull request #784 from dustinwloring1988/fix/prompt-enhance #release
fix: prompt enhance patch
2024-12-18 02:29:45 +05:30
Anirban Kar
d51b0dcadf Merge branch 'main' into fix/prompt-enhance 2024-12-18 02:28:46 +05:30
Anirban Kar
18d04ca065 simplified the fix 2024-12-18 02:20:14 +05:30
GK
8d0f0da04a add: xAI grok-2-1212 model 2024-12-17 17:31:10 +00:00
Anirban Kar
d327cfea29 feat: added terminal error capturing and automated fix prompt 2024-12-17 21:19:43 +05:30
Dustin Loring
0e7937b835 Update README.md 2024-12-17 10:27:04 -05:00
github-actions[bot]
a2330084eb chore: update commit hash to 282beb96e2 2024-12-17 15:25:41 +00:00
Dustin Loring
282beb96e2 docs: miniflare error knowledge
added miniflare and wrangler error on Windows FAQ to the docs
2024-12-17 10:25:30 -05:00
Dustin Loring
206a6d7816 add/toc-for-readme
added a TOC for the README file, renamed some headings to better suite the TOC
2024-12-17 10:19:40 -05:00
Dustin Loring
defe73367e add: miniflare and wrangler error
added information to FAQ in docs
2024-12-17 07:56:25 -05:00
Anirban Kar
42bde1cae4 Merge branch 'main' into terminal-error-detection 2024-12-17 16:52:51 +05:30
Anirban Kar
e9e7857675 removed logs 2024-12-17 15:01:00 +05:30
Anirban Kar
69c0bf5873 fix: added wait till terminal prompt for bolt shell execution 2024-12-17 14:57:16 +05:30
Dustin Loring
2d57bd2746 add: charactors to the vaild list for chat titles
added common punctuation to the charactersValid
2024-12-16 18:55:38 -05:00
Dustin Loring
05146c18d6 fix: Prompt Enhance
Prompt Enhance option stopped, this fixes it
2024-12-16 18:40:04 -05:00
github-actions[bot]
0ee3736297 chore: update commit hash to 1e72d52278 2024-12-16 22:04:55 +00:00
Anirban Kar
1e72d52278 Merge pull request #781 from thecodacus/semantic-pull-pr
chore: Reintroducing semantic pull pr validation
2024-12-17 03:34:42 +05:30
Anirban Kar
35f5aaaa76 chore: adding back semantic pull pr check for better changelog system 2024-12-17 03:32:08 +05:30
Anirban Kar
d238c9353c Merge remote-tracking branch 'upstream/main' 2024-12-17 03:30:54 +05:30
Anirban Kar
99cbde505d Merge branch 'main' into terminal-error-detection 2024-12-16 21:59:36 +05:30
kris1803
2064c83177 Fixed console error for SettingsWIndow & Removed ts-nocheck where not needed 2024-12-14 00:39:27 +01:00
270 changed files with 33482 additions and 7058 deletions

View File

@@ -32,7 +32,8 @@ OPEN_ROUTER_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
# You only need this environment variable set if you want to use oLLAMA models
# EXAMPLE http://localhost:11434
# DONT USE http://localhost:11434 due to IPV6 issues
# USE EXAMPLE http://127.0.0.1:11434
OLLAMA_API_BASE_URL=
# You only need this environment variable set if you want to use OpenAI Like models
@@ -50,6 +51,12 @@ OPENAI_LIKE_API_KEY=
# Get your Together API Key
TOGETHER_API_KEY=
# You only need this environment variable set if you want to use Hyperbolic models
#Get your Hyperbolics API Key at https://app.hyperbolic.xyz/settings
#baseURL="https://api.hyperbolic.xyz/v1/chat/completions"
HYPERBOLIC_API_KEY=
HYPERBOLIC_API_BASE_URL=
# Get your Mistral API Key by following these instructions -
# https://console.mistral.ai/api-keys/
# You only need this environment variable set if you want to use Mistral models
@@ -62,7 +69,8 @@ COHERE_API_KEY=
# Get LMStudio Base URL from LM Studio Developer Console
# Make sure to enable CORS
# Example: http://localhost:1234
# DONT USE http://localhost:1234 due to IPV6 issues
# Example: http://127.0.0.1:1234
LMSTUDIO_API_BASE_URL=
# Get your xAI API key
@@ -75,6 +83,17 @@ XAI_API_KEY=
# You only need this environment variable set if you want to use Perplexity models
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
VITE_LOG_LEVEL=debug

View File

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

261
.github/scripts/generate-changelog.sh vendored Executable file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# Ensure we're running in bash
if [ -z "$BASH_VERSION" ]; then
echo "This script requires bash. Please run with: bash $0" >&2
exit 1
fi
# Ensure we're using bash 4.0 or later for associative arrays
if ((BASH_VERSINFO[0] < 4)); then
echo "This script requires bash version 4 or later" >&2
echo "Current bash version: $BASH_VERSION" >&2
exit 1
fi
# Set default values for required environment variables if not in GitHub Actions
if [ -z "$GITHUB_ACTIONS" ]; then
: "${GITHUB_SERVER_URL:=https://github.com}"
: "${GITHUB_REPOSITORY:=stackblitz-labs/bolt.diy}"
: "${GITHUB_OUTPUT:=/tmp/github_output}"
touch "$GITHUB_OUTPUT"
# Running locally
echo "Running locally - checking for upstream remote..."
MAIN_REMOTE="origin"
if git remote -v | grep -q "upstream"; then
MAIN_REMOTE="upstream"
fi
MAIN_BRANCH="main" # or "master" depending on your repository
# Ensure we have latest tags
git fetch ${MAIN_REMOTE} --tags
# Use the remote reference for git log
GITLOG_REF="${MAIN_REMOTE}/${MAIN_BRANCH}"
else
# Running in GitHub Actions
GITLOG_REF="HEAD"
fi
# Get the latest tag
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
# Start changelog file
echo "# 🚀 Release v${NEW_VERSION}" > changelog.md
echo "" >> changelog.md
echo "## What's Changed 🌟" >> changelog.md
echo "" >> changelog.md
if [ -z "$LATEST_TAG" ]; then
echo "### 🎉 First Release" >> changelog.md
echo "" >> changelog.md
echo "Exciting times! This marks our first release. Thanks to everyone who contributed! 🙌" >> changelog.md
echo "" >> changelog.md
COMPARE_BASE="$(git rev-list --max-parents=0 HEAD)"
else
echo "### 🔄 Changes since $LATEST_TAG" >> changelog.md
echo "" >> changelog.md
COMPARE_BASE="$LATEST_TAG"
fi
# Function to extract conventional commit type and associated emoji
get_commit_type() {
local msg="$1"
if [[ $msg =~ ^feat(\(.+\))?:|^feature(\(.+\))?: ]]; then echo "✨ Features"
elif [[ $msg =~ ^fix(\(.+\))?: ]]; then echo "🐛 Bug Fixes"
elif [[ $msg =~ ^docs(\(.+\))?: ]]; then echo "📚 Documentation"
elif [[ $msg =~ ^style(\(.+\))?: ]]; then echo "💎 Styles"
elif [[ $msg =~ ^refactor(\(.+\))?: ]]; then echo "♻️ Code Refactoring"
elif [[ $msg =~ ^perf(\(.+\))?: ]]; then echo "⚡ Performance Improvements"
elif [[ $msg =~ ^test(\(.+\))?: ]]; then echo "🧪 Tests"
elif [[ $msg =~ ^build(\(.+\))?: ]]; then echo "🛠️ Build System"
elif [[ $msg =~ ^ci(\(.+\))?: ]]; then echo "⚙️ CI"
elif [[ $msg =~ ^chore(\(.+\))?: ]]; then echo "" # Skip chore commits
else echo "🔍 Other Changes" # Default category with emoji
fi
}
# Initialize associative arrays
declare -A CATEGORIES
declare -A COMMITS_BY_CATEGORY
declare -A ALL_AUTHORS
declare -A NEW_CONTRIBUTORS
# Get all historical authors before the compare base
while IFS= read -r author; do
ALL_AUTHORS["$author"]=1
done < <(git log "${COMPARE_BASE}" --pretty=format:"%ae" | sort -u)
# Process all commits since last tag
while IFS= read -r commit_line; do
if [[ ! $commit_line =~ ^[a-f0-9]+\| ]]; then
echo "WARNING: Skipping invalid commit line format: $commit_line" >&2
continue
fi
HASH=$(echo "$commit_line" | cut -d'|' -f1)
COMMIT_MSG=$(echo "$commit_line" | cut -d'|' -f2)
BODY=$(echo "$commit_line" | cut -d'|' -f3)
# Skip if hash doesn't match the expected format
if [[ ! $HASH =~ ^[a-f0-9]{40}$ ]]; then
continue
fi
HASH=$(echo "$commit_line" | cut -d'|' -f1)
COMMIT_MSG=$(echo "$commit_line" | cut -d'|' -f2)
BODY=$(echo "$commit_line" | cut -d'|' -f3)
# Validate hash format
if [[ ! $HASH =~ ^[a-f0-9]{40}$ ]]; then
echo "WARNING: Invalid commit hash format: $HASH" >&2
continue
fi
# Check if it's a merge commit
if [[ $COMMIT_MSG =~ Merge\ pull\ request\ #([0-9]+) ]]; then
# echo "Processing as merge commit" >&2
PR_NUM="${BASH_REMATCH[1]}"
# Extract the PR title from the merge commit body
PR_TITLE=$(echo "$BODY" | grep -v "^Merge pull request" | head -n 1)
# Only process if it follows conventional commit format
CATEGORY=$(get_commit_type "$PR_TITLE")
if [ -n "$CATEGORY" ]; then # Only process if it's a conventional commit
# Get PR author's GitHub username
GITHUB_USERNAME=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
if [ -n "$GITHUB_USERNAME" ]; then
# Check if this is a first-time contributor
AUTHOR_EMAIL=$(git show -s --format='%ae' "$HASH")
if [ -z "${ALL_AUTHORS[$AUTHOR_EMAIL]}" ]; then
NEW_CONTRIBUTORS["$GITHUB_USERNAME"]=1
ALL_AUTHORS["$AUTHOR_EMAIL"]=1
fi
CATEGORIES["$CATEGORY"]=1
COMMITS_BY_CATEGORY["$CATEGORY"]+="* ${PR_TITLE#*: } ([#$PR_NUM](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$PR_NUM)) by @$GITHUB_USERNAME"$'\n'
else
COMMITS_BY_CATEGORY["$CATEGORY"]+="* ${PR_TITLE#*: } ([#$PR_NUM](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$PR_NUM))"$'\n'
fi
fi
# Check if it's a squash merge by looking for (#NUMBER) pattern
elif [[ $COMMIT_MSG =~ \(#([0-9]+)\) ]]; then
# echo "Processing as squash commit" >&2
PR_NUM="${BASH_REMATCH[1]}"
# Only process if it follows conventional commit format
CATEGORY=$(get_commit_type "$COMMIT_MSG")
if [ -n "$CATEGORY" ]; then # Only process if it's a conventional commit
# Get PR author's GitHub username
GITHUB_USERNAME=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
if [ -n "$GITHUB_USERNAME" ]; then
# Check if this is a first-time contributor
AUTHOR_EMAIL=$(git show -s --format='%ae' "$HASH")
if [ -z "${ALL_AUTHORS[$AUTHOR_EMAIL]}" ]; then
NEW_CONTRIBUTORS["$GITHUB_USERNAME"]=1
ALL_AUTHORS["$AUTHOR_EMAIL"]=1
fi
CATEGORIES["$CATEGORY"]=1
COMMIT_TITLE=${COMMIT_MSG%% (#*} # Remove the PR number suffix
COMMIT_TITLE=${COMMIT_TITLE#*: } # Remove the type prefix
COMMITS_BY_CATEGORY["$CATEGORY"]+="* $COMMIT_TITLE ([#$PR_NUM](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$PR_NUM)) by @$GITHUB_USERNAME"$'\n'
else
COMMIT_TITLE=${COMMIT_MSG%% (#*} # Remove the PR number suffix
COMMIT_TITLE=${COMMIT_TITLE#*: } # Remove the type prefix
COMMITS_BY_CATEGORY["$CATEGORY"]+="* $COMMIT_TITLE ([#$PR_NUM](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$PR_NUM))"$'\n'
fi
fi
else
# echo "Processing as regular commit" >&2
# Process conventional commits without PR numbers
CATEGORY=$(get_commit_type "$COMMIT_MSG")
if [ -n "$CATEGORY" ]; then # Only process if it's a conventional commit
# Get commit author info
AUTHOR_EMAIL=$(git show -s --format='%ae' "$HASH")
# Try to get GitHub username using gh api
if [ -n "$GITHUB_ACTIONS" ] || command -v gh >/dev/null 2>&1; then
GITHUB_USERNAME=$(gh api "/repos/${GITHUB_REPOSITORY}/commits/${HASH}" --jq '.author.login' 2>/dev/null)
fi
if [ -n "$GITHUB_USERNAME" ]; then
# If we got GitHub username, use it
if [ -z "${ALL_AUTHORS[$AUTHOR_EMAIL]}" ]; then
NEW_CONTRIBUTORS["$GITHUB_USERNAME"]=1
ALL_AUTHORS["$AUTHOR_EMAIL"]=1
fi
CATEGORIES["$CATEGORY"]=1
COMMIT_TITLE=${COMMIT_MSG#*: } # Remove the type prefix
COMMITS_BY_CATEGORY["$CATEGORY"]+="* $COMMIT_TITLE (${HASH:0:7}) by @$GITHUB_USERNAME"$'\n'
else
# Fallback to git author name if no GitHub username found
AUTHOR_NAME=$(git show -s --format='%an' "$HASH")
if [ -z "${ALL_AUTHORS[$AUTHOR_EMAIL]}" ]; then
NEW_CONTRIBUTORS["$AUTHOR_NAME"]=1
ALL_AUTHORS["$AUTHOR_EMAIL"]=1
fi
CATEGORIES["$CATEGORY"]=1
COMMIT_TITLE=${COMMIT_MSG#*: } # Remove the type prefix
COMMITS_BY_CATEGORY["$CATEGORY"]+="* $COMMIT_TITLE (${HASH:0:7}) by $AUTHOR_NAME"$'\n'
fi
fi
fi
done < <(git log "${COMPARE_BASE}..${GITLOG_REF}" --pretty=format:"%H|%s|%b" --reverse --first-parent)
# Write categorized commits to changelog with their emojis
for category in "✨ Features" "🐛 Bug Fixes" "📚 Documentation" "💎 Styles" "♻️ Code Refactoring" "⚡ Performance Improvements" "🧪 Tests" "🛠️ Build System" "⚙️ CI" "🔍 Other Changes"; do
if [ -n "${COMMITS_BY_CATEGORY[$category]}" ]; then
echo "### $category" >> changelog.md
echo "" >> changelog.md
echo "${COMMITS_BY_CATEGORY[$category]}" >> changelog.md
echo "" >> changelog.md
fi
done
# Add first-time contributors section if there are any
if [ ${#NEW_CONTRIBUTORS[@]} -gt 0 ]; then
echo "## ✨ First-time Contributors" >> changelog.md
echo "" >> changelog.md
echo "A huge thank you to our amazing new contributors! Your first contribution marks the start of an exciting journey! 🌟" >> changelog.md
echo "" >> changelog.md
# Use readarray to sort the keys
readarray -t sorted_contributors < <(printf '%s\n' "${!NEW_CONTRIBUTORS[@]}" | sort)
for github_username in "${sorted_contributors[@]}"; do
echo "* 🌟 [@$github_username](https://github.com/$github_username)" >> changelog.md
done
echo "" >> changelog.md
fi
# Add compare link if not first release
if [ -n "$LATEST_TAG" ]; then
echo "## 📈 Stats" >> changelog.md
echo "" >> changelog.md
echo "**Full Changelog**: [\`$LATEST_TAG..v${NEW_VERSION}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/$LATEST_TAG...v${NEW_VERSION})" >> changelog.md
fi
# Output the changelog content
CHANGELOG_CONTENT=$(cat changelog.md)
{
echo "content<<EOF"
echo "$CHANGELOG_CONTENT"
echo "EOF"
} >> "$GITHUB_OUTPUT"
# Also print to stdout for local testing
echo "Generated changelog:"
echo "==================="
cat changelog.md
echo "==================="

View File

@@ -1,39 +0,0 @@
name: Update Commit Hash File
on:
push:
branches:
- main
permissions:
contents: write
jobs:
update-commit:
if: contains(github.event.head_commit.message, '#release') != true
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get the latest commit hash
run: |
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "CURRENT_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: Update commit file
run: |
echo "{ \"commit\": \"$COMMIT_HASH\", \"version\": \"$CURRENT_VERSION\" }" > app/commit.json
- name: Commit and push the update
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add app/commit.json
git commit -m "chore: update commit hash to $COMMIT_HASH"
git push

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

@@ -0,0 +1,61 @@
name: Docker Publish
on:
push:
branches:
- main
- stable
workflow_dispatch:
permissions:
packages: write
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker-build-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image for main
if: github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Docker image for stable
if: github.ref == 'refs/heads/stable'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- 'docs/**' # This will only trigger the workflow when files in docs directory change
permissions:
contents: write
jobs:

32
.github/workflows/semantic-pr.yaml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Semantic Pull Request
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
permissions:
pull-requests: read
jobs:
main:
name: Validate PR Title
runs-on: ubuntu-latest
steps:
# https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
types: |
fix
feat
chore
build
ci
perf
docs
refactor
revert
test

View File

@@ -14,12 +14,12 @@ jobs:
uses: actions/stale@v8
with:
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-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-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.'
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
stale-issue-label: "stale" # Label to apply to stale issues
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-pr-labels: "pinned,important" # PRs with these labels won't be marked stale
stale-issue-label: 'stale' # Label to apply to stale issues
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-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

View File

@@ -80,83 +80,16 @@ jobs:
NEW_VERSION=${{ steps.bump_version.outputs.new_version }}
pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version
- name: Prepare changelog script
run: chmod +x .github/scripts/generate-changelog.sh
- name: Generate Changelog
id: changelog
run: |
# Get the latest tag
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
env:
NEW_VERSION: ${{ steps.bump_version.outputs.new_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Start changelog file
echo "# Release v${{ steps.bump_version.outputs.new_version }}" > changelog.md
echo "" >> changelog.md
if [ -z "$LATEST_TAG" ]; then
echo "### 🎉 First Release" >> changelog.md
echo "" >> changelog.md
COMPARE_BASE="$(git rev-list --max-parents=0 HEAD)"
else
echo "### 🔄 Changes since $LATEST_TAG" >> changelog.md
echo "" >> changelog.md
COMPARE_BASE="$LATEST_TAG"
fi
# Function to extract conventional commit type
get_commit_type() {
if [[ $1 =~ ^feat:|^feature: ]]; then echo "✨ Features";
elif [[ $1 =~ ^fix: ]]; then echo "🐛 Bug Fixes";
elif [[ $1 =~ ^docs: ]]; then echo "📚 Documentation";
elif [[ $1 =~ ^style: ]]; then echo "💎 Styles";
elif [[ $1 =~ ^refactor: ]]; then echo "♻️ Code Refactoring";
elif [[ $1 =~ ^perf: ]]; then echo "⚡️ Performance Improvements";
elif [[ $1 =~ ^test: ]]; then echo "✅ Tests";
elif [[ $1 =~ ^build: ]]; then echo "🛠️ Build System";
elif [[ $1 =~ ^ci: ]]; then echo "⚙️ CI";
elif [[ $1 =~ ^chore: ]]; then echo "🔧 Chores";
else echo "🔍 Other Changes";
fi
}
# Generate categorized changelog
declare -A CATEGORIES
declare -A COMMITS_BY_CATEGORY
# Get commits since last tag or all commits if no tag exists
while IFS= read -r commit_line; do
HASH=$(echo "$commit_line" | cut -d'|' -f1)
MSG=$(echo "$commit_line" | cut -d'|' -f2)
PR_NUM=$(echo "$commit_line" | cut -d'|' -f3)
CATEGORY=$(get_commit_type "$MSG")
CATEGORIES["$CATEGORY"]=1
# Format commit message with PR link if available
if [ -n "$PR_NUM" ]; then
COMMITS_BY_CATEGORY["$CATEGORY"]+="- ${MSG#*: } ([#$PR_NUM](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$PR_NUM))"$'\n'
else
COMMITS_BY_CATEGORY["$CATEGORY"]+="- ${MSG#*: }"$'\n'
fi
done < <(git log "${COMPARE_BASE}..HEAD" --pretty=format:"%H|%s|%(trailers:key=PR-Number,valueonly)" --reverse)
# Write categorized commits to changelog
for category in "✨ Features" "🐛 Bug Fixes" "📚 Documentation" "💎 Styles" "♻️ Code Refactoring" "⚡️ Performance Improvements" "✅ Tests" "🛠️ Build System" "⚙️ CI" "🔧 Chores" "🔍 Other Changes"; do
if [ -n "${COMMITS_BY_CATEGORY[$category]}" ]; then
echo "#### $category" >> changelog.md
echo "" >> changelog.md
echo "${COMMITS_BY_CATEGORY[$category]}" >> changelog.md
echo "" >> changelog.md
fi
done
# Add compare link if not first release
if [ -n "$LATEST_TAG" ]; then
echo "**Full Changelog**: [\`$LATEST_TAG..v${{ steps.bump_version.outputs.new_version }}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/$LATEST_TAG...v${{ steps.bump_version.outputs.new_version }})" >> changelog.md
fi
# Save changelog content for the release
CHANGELOG_CONTENT=$(cat changelog.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
run: .github/scripts/generate-changelog.sh
- name: Get the latest commit hash and version tag
run: |
@@ -166,8 +99,7 @@ jobs:
- name: Commit and Tag Release
run: |
git pull
echo "{ \"commit\": \"$COMMIT_HASH\", \"version\": \"$NEW_VERSION\" }" > app/commit.json
git add package.json pnpm-lock.yaml changelog.md app/commit.json
git add package.json pnpm-lock.yaml changelog.md
git commit -m "chore: release version ${{ steps.bump_version.outputs.new_version }}"
git tag "v${{ steps.bump_version.outputs.new_version }}"
git push
@@ -187,7 +119,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="v${{ steps.bump_version.outputs.new_version }}"
# Save changelog to a file
echo "${{ steps.changelog.outputs.content }}" > release_notes.md
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.content }}" \
--notes-file release_notes.md \
--target stable

4
.gitignore vendored
View File

@@ -40,3 +40,7 @@ site
# commit file ignore
app/commit.json
changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
*.md

View File

@@ -29,15 +29,4 @@ if ! pnpm lint; then
exit 1
fi
# Update commit.json with the latest commit hash
echo "Updating commit.json with the latest commit hash..."
COMMIT_HASH=$(git rev-parse HEAD)
if [ $? -ne 0 ]; then
echo "❌ Failed to get commit hash. Ensure you are in a git repository."
exit 1
fi
echo "{ \"commit\": \"$COMMIT_HASH\" }" > app/commit.json
git add app/commit.json
echo "👍 All checks passed! Committing changes..."

View File

@@ -1,111 +1,117 @@
# Contributing to bolt.diy
# Contribution Guidelines
First off, thank you for considering contributing to bolt.diy! This fork aims to expand the capabilities of the original project by integrating multiple LLM providers and enhancing functionality. Every contribution helps make bolt.diy a better tool for developers worldwide.
Welcome! This guide provides all the details you need to contribute effectively to the project. Thank you for helping us make **bolt.diy** a better tool for developers worldwide. 💡
---
## 📋 Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Coding Standards](#coding-standards)
- [Development Setup](#development-setup)
- [Deploymnt with Docker](#docker-deployment-documentation)
- [Project Structure](#project-structure)
## Code of Conduct
1. [Code of Conduct](#code-of-conduct)
2. [How Can I Contribute?](#how-can-i-contribute)
3. [Pull Request Guidelines](#pull-request-guidelines)
4. [Coding Standards](#coding-standards)
5. [Development Setup](#development-setup)
6. [Testing](#testing)
7. [Deployment](#deployment)
8. [Docker Deployment](#docker-deployment)
9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration)
This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
---
## How Can I Contribute?
## 🛡️ Code of Conduct
### 🐞 Reporting Bugs and Feature Requests
- Check the issue tracker to avoid duplicates
- Use the issue templates when available
- Include as much relevant information as possible
- For bugs, add steps to reproduce the issue
This project is governed by our **Code of Conduct**. By participating, you agree to uphold this code. Report unacceptable behavior to the project maintainers.
### 🔧 Code Contributions
1. Fork the repository
2. Create a new branch for your feature/fix
3. Write your code
4. Submit a pull request
---
### ✨ Becoming a Core Contributor
We're looking for dedicated contributors to help maintain and grow this project. If you're interested in becoming a core contributor, please fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
## 🛠️ How Can I Contribute?
## Pull Request Guidelines
### 1⃣ Reporting Bugs or Feature Requests
### 📝 PR Checklist
- [ ] Branch from the main branch
- [ ] Update documentation if needed
- [ ] Manually verify all new functionality works as expected
- [ ] Keep PRs focused and atomic
- Check the [issue tracker](#) to avoid duplicates.
- Use issue templates (if available).
- Provide detailed, relevant information and steps to reproduce bugs.
### 👀 Review Process
1. Manually test the changes
2. At least one maintainer review required
3. Address all review comments
4. Maintain clean commit history
### 2⃣ Code Contributions
## Coding Standards
1. Fork the repository.
2. Create a feature or fix branch.
3. Write and test your code.
4. Submit a pull request (PR).
### 💻 General Guidelines
- Follow existing code style
- Comment complex logic
- Keep functions focused and small
- Use meaningful variable names
- Lint your code. This repo contains a pre-commit-hook that will verify your code is linted properly,
so set up your IDE to do that for you!
### 3⃣ Join as a Core Contributor
## Development Setup
Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
### 🔄 Initial Setup
1. Clone the repository:
---
## ✅ Pull Request Guidelines
### PR Checklist
- Branch from the **main** branch.
- Update documentation, if needed.
- Test all functionality manually.
- 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.
---
## 📏 Coding Standards
### General Guidelines
- Follow existing code style.
- Comment complex logic.
- Keep functions small and focused.
- Use meaningful variable names.
---
## 🖥️ Development Setup
### 1⃣ Initial Setup
- Clone the repository:
```bash
git clone https://github.com/coleam00/bolt.new-any-llm.git
git clone https://github.com/stackblitz-labs/bolt.diy.git
```
2. Install dependencies:
- Install dependencies:
```bash
pnpm install
```
3. Set up environment variables:
- Rename `.env.example` to `.env.local`
- Add your LLM API keys (only set the ones you plan to use):
- Set up environment variables:
1. Rename `.env.example` to `.env.local`.
2. Add your API keys:
```bash
GROQ_API_KEY=XXX
HuggingFace_API_KEY=XXX
OPENAI_API_KEY=XXX
ANTHROPIC_API_KEY=XXX
...
```
- Optionally set debug level:
```bash
VITE_LOG_LEVEL=debug
```
3. Optionally set:
- Debug level: `VITE_LOG_LEVEL=debug`
- Context size: `DEFAULT_NUM_CTX=32768`
- Optionally set context size:
```bash
DEFAULT_NUM_CTX=32768
```
**Note**: Never commit your `.env.local` file to version control. Its already in `.gitignore`.
Some Example Context Values for the qwen2.5-coder:32b models are.
### 2⃣ Run Development Server
* DEFAULT_NUM_CTX=32768 - Consumes 36GB of VRAM
* DEFAULT_NUM_CTX=24576 - Consumes 32GB of VRAM
* DEFAULT_NUM_CTX=12288 - Consumes 26GB of VRAM
* DEFAULT_NUM_CTX=6144 - Consumes 24GB of VRAM
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
### 🚀 Running the Development Server
```bash
pnpm run dev
```
**Note**: You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway.
**Tip**: Use **Google Chrome Canary** for local testing.
## Testing
---
## 🧪 Testing
Run the test suite with:
@@ -113,105 +119,124 @@ Run the test suite with:
pnpm test
```
## Deployment
---
To deploy the application to Cloudflare Pages:
## 🚀 Deployment
### Deploy to Cloudflare Pages
```bash
pnpm run deploy
```
Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.
Ensure you have required permissions and that Wrangler is configured.
# Docker Deployment Documentation
---
This guide outlines various methods for building and deploying the application using Docker.
## 🐳 Docker Deployment
## Build Methods
This section outlines the methods for deploying the application using Docker. The processes for **Development** and **Production** are provided separately for clarity.
### 1. Using Helper Scripts
---
NPM scripts are provided for convenient building:
### 🧑‍💻 Development Environment
#### Build Options
**Option 1: Helper Scripts**
```bash
# Development build
npm run dockerbuild
```
**Option 2: Direct Docker Build Command**
```bash
docker build . --target bolt-ai-development
```
**Option 3: Docker Compose Profile**
```bash
docker compose --profile development up
```
#### Running the Development Container
```bash
docker run -p 5173:5173 --env-file .env.local bolt-ai:development
```
---
### 🏭 Production Environment
#### Build Options
**Option 1: Helper Scripts**
```bash
# Production build
npm run dockerbuild:prod
```
### 2. Direct Docker Build Commands
You can use Docker's target feature to specify the build environment:
**Option 2: Direct Docker Build Command**
```bash
# Development build
docker build . --target bolt-ai-development
# Production build
docker build . --target bolt-ai-production
```
### 3. Docker Compose with Profiles
**Option 3: Docker Compose Profile**
Use Docker Compose profiles to manage different environments:
```bash
# Development environment
docker-compose --profile development up
# Production environment
docker-compose --profile production up
```
## Running the Application
After building using any of the methods above, run the container with:
```bash
# Development
docker run -p 5173:5173 --env-file .env.local bolt-ai:development
# Production
docker run -p 5173:5173 --env-file .env.local bolt-ai:production
```
## Deployment with Coolify
[Coolify](https://github.com/coollabsio/coolify) provides a straightforward deployment process:
1. Import your Git repository as a new project
2. Select your target environment (development/production)
3. Choose "Docker Compose" as the Build Pack
4. Configure deployment domains
5. Set the custom start command:
```bash
docker compose --profile production up
```
6. Configure environment variables
- Add necessary AI API keys
- Adjust other environment variables as needed
7. Deploy the application
## VS Code Integration
#### Running the Production Container
The `docker-compose.yaml` configuration is compatible with VS Code dev containers:
```bash
docker run -p 5173:5173 --env-file .env.local bolt-ai:production
```
1. Open the command palette in VS Code
2. Select the dev container configuration
3. Choose the "development" profile from the context menu
---
## Environment Files
### Coolify Deployment
Ensure you have the appropriate `.env.local` file configured before running the containers. This file should contain:
- API keys
- Environment-specific configurations
- Other required environment variables
For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify):
## Notes
1. Import your Git repository into Coolify.
2. Choose **Docker Compose** as the build pack.
3. Configure environment variables (e.g., API keys).
4. Set the start command:
```bash
docker compose --profile production up
```
- Port 5173 is exposed and mapped for both development and production environments
- Environment variables are loaded from `.env.local`
- Different profiles (development/production) can be used for different deployment scenarios
- The configuration supports both local development and production deployment
---
## 🛠️ VS Code Dev Containers Integration
The `docker-compose.yaml` configuration is compatible with **VS Code Dev Containers**, making it easy to set up a development environment directly in Visual Studio Code.
### Steps to Use Dev Containers
1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS).
2. Select **Dev Containers: Reopen in Container**.
3. Choose the **development** profile when prompted.
4. VS Code will rebuild the container and open it with the pre-configured environment.
---
## 🔑 Environment Variables
Ensure `.env.local` is configured correctly with:
- API keys.
- Context-specific configurations.
Example for the `DEFAULT_NUM_CTX` variable:
```bash
DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM
```

View File

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

106
FAQ.md
View File

@@ -1,47 +1,105 @@
[![bolt.diy: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.diy)
# Frequently Asked Questions (FAQ)
# bolt.diy
<details>
<summary><strong>What are the best models for bolt.diy?</strong></summary>
## FAQ
For the best experience with bolt.diy, we recommend using the following models:
### How do I get the best results with bolt.diy?
- **Claude 3.5 Sonnet (old)**: Best overall coder, providing excellent results across all use cases
- **Gemini 2.0 Flash**: Exceptional speed while maintaining good performance
- **GPT-4o**: Strong alternative to Claude 3.5 Sonnet with comparable capabilities
- **DeepSeekCoder V2 236b**: Best open source model (available through OpenRouter, DeepSeek API, or self-hosted)
- **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements
- **Be specific about your stack**: If you want to use specific frameworks or libraries (like Astro, Tailwind, ShadCN, or any other popular JavaScript framework), mention them in your initial prompt to ensure bolt scaffolds the project accordingly.
**Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt!
- **Use the enhance prompt icon**: Before sending your prompt, try clicking the 'enhance' icon to have the AI model help you refine your prompt, then edit the results before submitting.
</details>
- **Scaffold the basics first, then add features**: Make sure the basic structure of your application is in place before diving into more advanced functionality. This helps Bolt.diy understand the foundation of your project and ensure everything is wired up right before building out more advanced functionality.
<details>
<summary><strong>How do I get the best results with bolt.diy?</strong></summary>
- **Batch simple instructions**: Save time by combining simple instructions into one message. For example, you can ask Bolt.diy to change the color scheme, add mobile responsiveness, and restart the dev server, all in one go saving you time and reducing API credit consumption significantly.
- **Be specific about your stack**:
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.
### Why are there so many open issues/pull requests?
- **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.
bolt.diy was started simply to showcase how to edit an open source project and to do something cool with local LLMs on my (@ColeMedin) YouTube channel! However, it quickly grew into a massive community project that I am working hard to keep up with the demand of by forming a team of maintainers and getting as many people involved as I can. That effort is going well and all of our maintainers are ABSOLUTE rockstars, but it still takes time to organize everything so we can efficiently get through all the issues and PRs. But rest assured, we are working hard and even working on some partnerships behind the scenes to really help this project take off!
- **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.
### How do local LLMs fair compared to larger models like Claude 3.5 Sonnet for bolt.diy/bolt.new?
- **Batch simple instructions**:
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."_
</details>
As much as the gap is quickly closing between open source and massive close source models, youre still going to get the best results with the very large models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b. This is one of the big tasks we have at hand - figuring out how to prompt better, use agents, and improve the platform as a whole to make it work better for even the smaller local LLMs!
<details>
<summary><strong>How do I contribute to bolt.diy?</strong></summary>
### I'm getting the error: "There was an error processing this request"
Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved!
If you see this error within bolt.diy, that is just the application telling you there is a problem at a high level, and this could mean a number of different things. To find the actual error, please check BOTH the terminal where you started the application (with Docker or pnpm) and the developer console in the browser. For most browsers, you can access the developer console by pressing F12 or right clicking anywhere in the browser and selecting “Inspect”. Then go to the “console” tab in the top right.
</details>
### I'm getting the error: "x-api-key header missing"
<details>
<summary><strong>What are the future plans for bolt.diy?</strong></summary>
We have seen this error a couple times and for some reason just restarting the Docker container has fixed it. This seems to be Ollama specific. Another thing to try is try to run bolt.diy with Docker or pnpm, whichever you didnt run first. We are still on the hunt for why this happens once and a while!
Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates.
New features and improvements are on the way!
### I'm getting a blank preview when bolt.diy runs my app!
</details>
We promise you that we are constantly testing new PRs coming into bolt.diy and the preview is core functionality, so the application is not broken! When you get a blank preview or dont get a preview, this is generally because the LLM hallucinated bad code or incorrect commands. We are working on making this more transparent so it is obvious. Sometimes the error will appear in developer console too so check that as well.
<details>
<summary><strong>Why are there so many open issues/pull requests?</strong></summary>
### How to add a LLM:
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!
To make new LLMs available to use in this version of bolt.new, head on over to `app/utils/constants.ts` and find the constant MODEL_LIST. Each element in this array is an object that has the model ID for the name (get this from the provider's API documentation), a label for the frontend model dropdown, and the provider.
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.
By default, Anthropic, OpenAI, Groq, and Ollama are implemented as providers, but the YouTube video for this repo covers how to extend this to work with more providers if you wish!
</details>
When you add a new model to the MODEL_LIST array, it will immediately be available to use when you run the app locally or reload it. For Ollama models, make sure you have the model installed already before trying to use it here!
<details>
<summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary>
### Everything works but the results are bad
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.
This goes to the point above about how local LLMs are getting very powerful but you still are going to see better (sometimes much better) results with the largest LLMs like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b. If you are using smaller LLMs like Qwen-2.5-Coder, consider it more experimental and educational at this point. It can build smaller applications really well, which is super impressive for a local LLM, but for larger scale applications you want to use the larger LLMs still!
</details>
<details>
<summary><strong>Common Errors and Troubleshooting</strong></summary>
### **"There was an error processing this request"**
This generic error message means something went wrong. Check both:
- 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).
### **"x-api-key header missing"**
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.
### **Blank preview when running the app**
A blank preview often occurs due to hallucinated bad code or incorrect commands.
To troubleshoot:
- 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.
### **"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.
### **"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)
### **"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.
</details>
---
Got more questions? Feel free to reach out or open an issue in our GitHub repo!

View File

@@ -1,6 +1,6 @@
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
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 🙌

296
README.md
View File

@@ -1,19 +1,44 @@
[![bolt.diy: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.diy)
# bolt.diy (Previously oTToDev)
[![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.
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more information. This documentation is still being updated after the transfer.
-----
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/).
bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMedin) but has quickly grown into a massive community effort to build the BEST open source AI coding assistant!
## Join the community for bolt.diy!
## Table of Contents
https://thinktank.ottomator.ai
- [Join the Community](#join-the-community)
- [Requested Additions](#requested-additions)
- [Features](#features)
- [Setup](#setup)
- [Run the Application](#run-the-application)
- [Available Scripts](#available-scripts)
- [Contributing](#contributing)
- [Roadmap](#roadmap)
- [FAQ](#faq)
## Join the community
## Requested Additions - Feel Free to Contribute!
[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
- ✅ OpenRouter Integration (@coleam00)
- ✅ Gemini Integration (@jonathands)
@@ -34,6 +59,7 @@ https://thinktank.ottomator.ai
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
- ✅ Streaming of code output (@thecodacus)
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
- ✅ Chat history backup and restore functionality (@sidbetatester)
- ✅ Cohere Integration (@hasanraiyan)
- ✅ Dynamic model max token length (@hasanraiyan)
- ✅ Better prompt enhancing (@SujalXplores)
@@ -42,12 +68,18 @@ https://thinktank.ottomator.ai
- ✅ Together Integration (@mouimet-infinisoft)
- ✅ Mobile friendly (@qwikode)
- ✅ Better prompt enhancing (@SujalXplores)
- ✅ Attach images to prompts (@atrokhym)
- ✅ Attach images to prompts (@atrokhym)(@stijnus)
- ✅ Added Git Clone button (@thecodacus)
- ✅ Git Import from url (@thecodacus)
- ✅ PromptLibrary to have different variations of prompts for different use cases (@thecodacus)
- ✅ Detect package.json and commands to auto install & run preview for folder and git import (@wonderwhy-er)
- ✅ Selection tool to target changes visually (@emcconnell)
- ✅ Detect terminal Errors and ask bolt to fix it (@thecodacus)
- ✅ Detect preview Errors and ask bolt to fix it (@wonderwhy-er)
- ✅ Add Starter Template Options (@thecodacus)
- ✅ Perplexity Integration (@meetpateltech)
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
-**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** - Run agents in the backend as opposed to a single model call
@@ -57,12 +89,14 @@ https://thinktank.ottomator.ai
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
- ⬜ Voice prompting
- ⬜ Azure Open AI API Integration
- ⬜ Perplexity Integration
- ⬜ Vertex AI Integration
- ⬜ Granite Integration
- ✅ Popout Window for Web Container(@stijnus)
- ✅ Ability to change Popout window size (@stijnus)
## bolt.diy 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.
- **Attach images to prompts** for better contextual understanding.
- **Integrated terminal** to view output of LLM-run commands.
@@ -70,141 +104,219 @@ https://thinktank.ottomator.ai
- **Download projects as ZIP** for easy portability.
- **Integration-ready Docker support** for a hassle-free setup.
## Setup bolt.diy
## 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.
### Prerequisites
Let's get you up and running with the stable version of Bolt.DIY!
1. **Install Git**: [Download Git](https://git-scm.com/downloads)
2. **Install Node.js**: [Download Node.js](https://nodejs.org/en/download/)
## Quick Download
- After installation, the Node.js path is usually added to your system automatically. To verify:
- **Windows**: Search for "Edit the system environment variables," click "Environment Variables," and check if `Node.js` is in the `Path` variable.
- **Mac/Linux**: Open a terminal and run:
[![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**
## Prerequisites
Before you begin, you'll need to install two important pieces of software:
### Install Node.js
Node.js is required to run the application.
1. Visit the [Node.js Download Page](https://nodejs.org/en/download/)
2. Download the "LTS" (Long Term Support) version for your operating system
3. Run the installer, accepting the default settings
4. Verify Node.js is properly installed:
- **For Windows Users**:
1. Press `Windows + R`
2. Type "sysdm.cpl" and press Enter
3. Go to "Advanced" tab → "Environment Variables"
4. Check if `Node.js` appears in the "Path" variable
- **For Mac/Linux Users**:
1. Open Terminal
2. Type this command:
```bash
echo $PATH
```
Look for `/usr/local/bin` in the output.
3. Look for `/usr/local/bin` in the output
### Clone the Repository
## Running the Application
Clone the repository using Git:
You have two options for running Bolt.DIY: directly on your machine or using Docker.
### Option 1: Direct Installation (Recommended for Beginners)
1. **Install Package Manager (pnpm)**:
```bash
git clone -b stable https://github.com/stackblitz-labs/bolt.diy
npm install -g pnpm
```
### (Optional) Configure Environment Variables
2. **Install Project Dependencies**:
Most environment variables can be configured directly through the settings menu of the application. However, if you need to manually configure them:
1. Rename `.env.example` to `.env.local`.
2. Add your LLM API keys. For example:
```env
GROQ_API_KEY=YOUR_GROQ_API_KEY
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
```
**Note**: Ollama does not require an API key as it runs locally.
3. Optionally, set additional configurations:
```env
# Debugging
VITE_LOG_LEVEL=debug
# Ollama settings (example: 8K context, localhost port 11434)
OLLAMA_API_BASE_URL=http://localhost:11434
DEFAULT_NUM_CTX=8192
```
**Important**: Do not commit your `.env.local` file to version control. This file is already included in `.gitignore`.
---
## Run the Application
### Option 1: Without Docker
1. **Install Dependencies**:
```bash
pnpm install
```
If `pnpm` is not installed, install it using:
```bash
sudo npm install -g pnpm
```
2. **Start the Application**:
3. **Start the Application**:
```bash
pnpm run dev
```
This will start the Remix Vite development server. You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway.
### Option 2: With Docker
### Option 2: Using Docker
#### Prerequisites
- Ensure Git, Node.js, and Docker are installed: [Download Docker](https://www.docker.com/)
This option requires some familiarity with Docker but provides a more isolated environment.
#### Steps
#### Additional Prerequisite
- Install Docker: [Download Docker](https://www.docker.com/)
#### Steps:
1. **Build the Docker Image**:
Use the provided NPM scripts:
```bash
npm run dockerbuild # Development build
npm run dockerbuild:prod # Production build
```
# Using npm script:
npm run dockerbuild
Alternatively, use Docker commands directly:
```bash
docker build . --target bolt-ai-development # Development build
docker build . --target bolt-ai-production # Production build
# OR using direct Docker command:
docker build . --target bolt-ai-development
```
2. **Run the Container**:
Use Docker Compose profiles to manage environments:
```bash
docker-compose --profile development up # Development
docker-compose --profile production up # Production
docker compose --profile development up
```
- With the development profile, changes to your code will automatically reflect in the running container (hot reloading).
## Configuring API Keys and Providers
---
### Adding Your API Keys
### Update Your Local Version to the Latest
Setting up your API keys in Bolt.DIY is straightforward:
To keep your local version of bolt.diy up to date with the latest changes, follow these steps for your operating system:
1. Open the home page (main interface)
2. Select your desired provider from the dropdown menu
3. Click the pencil (edit) icon
4. Enter your API key in the secure input field
#### 1. **Navigate to your project folder**
Navigate to the directory where you cloned the repository and open a terminal:
![API Key Configuration Interface](./docs/images/api-key-ui-section.png)
#### 2. **Fetch the Latest Changes**
Use Git to pull the latest changes from the main repository:
### Configuring Custom Base URLs
For providers that support custom base URLs (such as Ollama or LM Studio), follow these steps:
1. Click the settings icon in the sidebar to open the settings menu
![Settings Button Location](./docs/images/bolt-settings-button.png)
2. Navigate to the "Providers" tab
3. Search for your provider using the search bar
4. Enter your custom base URL in the designated field
![Provider Base URL Configuration](./docs/images/provider-base-url.png)
> **Note**: Custom base URLs are particularly useful when running local instances of AI models or using custom API endpoints.
### Supported Providers
- Ollama
- LM Studio
- OpenAILike
## Setup Using Git (For Developers only)
This method is recommended for developers who want to:
- Contribute to the project
- Stay updated with the latest changes
- Switch between different versions
- Create custom modifications
#### Prerequisites
1. Install Git: [Download Git](https://git-scm.com/downloads)
#### Initial Setup
1. **Clone the Repository**:
```bash
# Using HTTPS
git clone https://github.com/stackblitz-labs/bolt.diy.git
```
2. **Navigate to Project Directory**:
```bash
cd bolt.diy
```
3. **Switch to the Main Branch**:
```bash
git checkout main
```
4. **Install Dependencies**:
```bash
pnpm install
```
5. **Start the Development Server**:
```bash
pnpm run dev
```
#### Staying Updated
To get the latest changes from the repository:
1. **Save Your Local Changes** (if any):
```bash
git stash
```
2. **Pull Latest Updates**:
```bash
git pull origin main
```
#### 3. **Update Dependencies**
After pulling the latest changes, update the project dependencies by running the following command:
3. **Update Dependencies**:
```bash
pnpm install
```
#### 4. **Run the Application**
Once the updates are complete, you can start the application again with:
4. **Restore Your Local Changes** (if any):
```bash
pnpm run dev
git stash pop
```
This ensures that you're running the latest version of bolt.diy and can take advantage of all the newest features and bug fixes.
#### Troubleshooting Git Setup
If you encounter issues:
1. **Clean Installation**:
```bash
# Remove node modules and lock files
rm -rf node_modules pnpm-lock.yaml
# Clear pnpm cache
pnpm store prune
# Reinstall dependencies
pnpm install
```
2. **Reset Local Changes**:
```bash
# Discard all local changes
git reset --hard origin/main
```
Remember to always commit your local changes or stash them before pulling updates to avoid conflicts.
---
@@ -236,4 +348,4 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
## FAQ
For answers to common questions, visit our [FAQ Page](FAQ.md).
For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).

View File

@@ -1 +0,0 @@
{ "commit": "89eac1ba8b2ada92dd76da9671d17e08de61a8bf", "version": "0.0.2" }

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,28 @@
import { motion } from 'framer-motion';
import { GithubConnection } from './GithubConnection';
import { NetlifyConnection } from './NetlifyConnection';
export default function ConnectionsTab() {
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">
<GithubConnection />
<NetlifyConnection />
</div>
</div>
);
}

View File

@@ -0,0 +1,557 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
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 function GithubConnection() {
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);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const fetchGitHubStats = async (token: string) => {
try {
setIsFetchingStats(true);
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[];
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[];
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);
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;
});
});
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);
}
};
useEffect(() => {
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
setConnection(parsed);
if (parsed.user && parsed.token) {
fetchGitHubStats(parsed.token);
}
}
setIsLoading(false);
}, []);
if (isLoading || isConnecting || isFetchingStats) {
return <LoadingSpinner />;
}
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,
};
localStorage.setItem('github_connection', JSON.stringify(newConnection));
setConnection(newConnection);
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');
};
return (
<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 && connection.stats && (
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
<div className="flex items-center gap-4">
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.user.name || connection.user.login}
</h3>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
{connection.user.bio && (
<p className="text-sm text-start 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:book-bookmark w-4 h-4" />
{connection.user.public_repos} public repos
</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>
</button>
{isStatsExpanded && (
<div className="pt-4">
{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>
{/* 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>
)}
</div>
</motion.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,263 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import {
netlifyConnection,
isConnecting,
isFetchingStats,
updateNetlifyConnection,
fetchNetlifyStats,
} from '~/lib/stores/netlify';
import type { NetlifyUser } from '~/types/netlify';
export function NetlifyConnection() {
const connection = useStore(netlifyConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
useEffect(() => {
const fetchSites = async () => {
if (connection.user && connection.token) {
await fetchNetlifyStats(connection.token);
}
};
fetchSites();
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const userData = (await response.json()) as NetlifyUser;
updateNetlifyConnection({
user: userData,
token: connection.token,
});
await fetchNetlifyStats(connection.token);
toast.success('Successfully connected to Netlify');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Netlify', { error });
toast.error('Failed to connect to Netlify');
updateNetlifyConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateNetlifyConnection({ user: null, token: '' });
toast.success('Disconnected from Netlify');
};
return (
<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.3 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
</div>
</div>
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Netlify personal access 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-[#00AD9F]',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#00AD9F] text-white',
'hover:bg-[#00968A]',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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 w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to Netlify
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<img
src={connection.user.avatar_url}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt={connection.user.full_name}
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Netlify sites...
</div>
) : (
<div>
<button
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings w-4 h-4" />
Your Sites ({connection.stats?.totalSites || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isSitesExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isSitesExpanded && connection.stats?.sites?.length ? (
<div className="grid gap-3">
{connection.stats.sites.map((site) => (
<a
key={site.id}
href={site.admin_url}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] 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:globe w-4 h-4 text-[#00AD9F]" />
{site.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url}
</a>
{site.published_deploy && (
<>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(site.published_deploy.published_at).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
{site.build_settings?.provider && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:git-branch w-3 h-3" />
{site.build_settings.provider}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isSitesExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No sites found in your Netlify account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.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,6 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { ProviderInfo } from '~/types/model';
import Cookies from 'js-cookie';
interface APIKeyManagerProps {
provider: ProviderInfo;
@@ -10,58 +11,159 @@ interface APIKeyManagerProps {
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> } = {};
export function getApiKeysFromCookies() {
const storedApiKeys = Cookies.get('apiKeys');
let parsedKeys: Record<string, string> = {};
if (storedApiKeys) {
parsedKeys = apiKeyMemoizeCache[storedApiKeys];
if (!parsedKeys) {
parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys);
}
}
return parsedKeys;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
const [isEditing, setIsEditing] = useState(false);
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 = () => {
// Save to parent state
setApiKey(tempKey);
// Save to cookies
const currentKeys = getApiKeysFromCookies();
const newKeys = { ...currentKeys, [provider.name]: tempKey };
Cookies.set('apiKeys', JSON.stringify(newKeys));
setIsEditing(false);
};
return (
<div className="flex items-start sm:items-center mt-2 mb-2 flex-col sm:flex-row">
<div>
<span className="text-sm text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
<div className="flex items-center justify-between py-3 px-1">
<div className="flex items-center gap-2 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
{!isEditing && (
<div className="flex items-center mb-4">
<span className="flex-1 text-xs text-bolt-elements-textPrimary mr-2">
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
</span>
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
<div className="i-ph:pencil-simple" />
</IconButton>
<div className="flex items-center gap-2">
{apiKey ? (
<>
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
<span className="text-xs text-green-500">Set via UI</span>
</>
) : 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 className="flex items-center gap-2 shrink-0">
{isEditing ? (
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center gap-2">
<input
type="password"
value={tempKey}
placeholder="Your API Key"
placeholder="Enter API Key"
onChange={(e) => setTempKey(e.target.value)}
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"
className="w-[300px] px-3 py-1.5 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"
/>
<IconButton onClick={handleSave} title="Save API Key">
<div className="i-ph:check" />
<IconButton
onClick={handleSave}
title="Save API Key"
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
>
<div className="i-ph:check w-4 h-4" />
</IconButton>
<IconButton onClick={() => setIsEditing(false)} title="Cancel">
<div className="i-ph:x" />
<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>
) : (
<>
{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
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>
);
};

View File

@@ -1,16 +1,55 @@
import { memo } from 'react';
import { Markdown } from './Markdown';
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 {
content: string;
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) => {
const filteredAnnotations = (annotations?.filter(
(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: {
completionTokens: number;
@@ -20,11 +59,54 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
return (
<div className="overflow-hidden w-full">
<>
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
{(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 className="text-sm text-bolt-elements-textSecondary mb-2">
<div>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
</div>
</>
<Markdown html>{content}</Markdown>
</div>
);

View File

@@ -2,17 +2,17 @@
* @ts-nocheck
* 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, useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
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 { SendButton } from './SendButton.client';
import { APIKeyManager } from './APIKeyManager';
import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager';
import Cookies from 'js-cookie';
import * as Tooltip from '@radix-ui/react-tooltip';
@@ -25,9 +25,17 @@ import GitCloneButton from './GitCloneButton';
import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
import type { ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify';
import StarterTemplates from './StarterTemplates';
import type { ActionAlert } from '~/types/actions';
import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -38,6 +46,7 @@ interface BaseChatProps {
showChat?: boolean;
chatStarted?: boolean;
isStreaming?: boolean;
onStreamingChange?: (streaming: boolean) => void;
messages?: Message[];
description?: string;
enhancingPrompt?: boolean;
@@ -58,6 +67,10 @@ interface BaseChatProps {
setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void;
actionAlert?: ActionAlert;
clearAlert?: () => void;
data?: JSONValue[] | undefined;
actionRunner?: ActionRunner;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -69,6 +82,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
showChat = true,
chatStarted = false,
isStreaming = false,
onStreamingChange,
model,
setModel,
provider,
@@ -89,76 +103,39 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
imageDataList = [],
setImageDataList,
messages,
actionAlert,
clearAlert,
data,
actionRunner,
},
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
const savedKeys = Cookies.get('apiKeys');
if (savedKeys) {
try {
return JSON.parse(savedKeys);
} catch (error) {
console.error('Failed to parse API keys from cookies:', error);
return {};
}
}
return {};
});
const [modelList, setModelList] = useState(MODEL_LIST);
const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies());
const [modelList, setModelList] = useState<ModelInfo[]>([]);
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState('');
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
useEffect(() => {
if (data) {
const progressList = data.filter(
(x) => typeof x === 'object' && (x as any).type === 'progress',
) as ProgressAnnotation[];
setProgressAnnotations(progressList);
}
}, [data]);
useEffect(() => {
console.log(transcript);
}, [transcript]);
useEffect(() => {
// Load API keys from cookies on component mount
try {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
const parsedKeys = JSON.parse(storedApiKeys);
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
setApiKeys(parsedKeys);
}
}
} catch (error) {
console.error('Error loading API keys from cookies:', error);
// Clear invalid cookie data
Cookies.remove('apiKeys');
}
let providerSettings: Record<string, IProviderSetting> | undefined = undefined;
try {
const savedProviderSettings = Cookies.get('providers');
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');
}
initializeModelList(providerSettings).then((modelList) => {
setModelList(modelList);
});
onStreamingChange?.(isStreaming);
}, [isStreaming, onStreamingChange]);
useEffect(() => {
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
@@ -190,6 +167,59 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
let parsedApiKeys: Record<string, string> | undefined = {};
try {
parsedApiKeys = getApiKeysFromCookies();
setApiKeys(parsedApiKeys);
} catch (error) {
console.error('Error loading API keys from cookies:', error);
Cookies.remove('apiKeys');
}
setIsModelLoading('all');
fetch('/api/models')
.then((response) => response.json())
.then((data) => {
const typedData = data as { modelList: ModelInfo[] };
setModelList(typedData.modelList);
})
.catch((error) => {
console.error('Error fetching model list:', error);
})
.finally(() => {
setIsModelLoading(undefined);
});
}
}, [providerList, provider]);
const onApiKeysChange = async (providerName: string, apiKey: string) => {
const newApiKeys = { ...apiKeys, [providerName]: apiKey };
setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
setIsModelLoading(providerName);
let providerModels: ModelInfo[] = [];
try {
const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
const data = await response.json();
providerModels = (data as { modelList: ModelInfo[] }).modelList;
} catch (error) {
console.error('Error loading dynamic models for:', providerName, error);
}
// Only update models for the specific provider
setModelList((prevModels) => {
const otherModels = prevModels.filter((model) => model.provider !== providerName);
return [...otherModels, ...providerModels];
});
setIsModelLoading(undefined);
};
const startListening = () => {
if (recognition) {
recognition.start();
@@ -299,6 +329,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
className={classNames('pt-6 px-2 sm:px-6', {
'h-full flex flex-col': chatStarted,
})}
ref={scrollRef}
>
<ClientOnly>
{() => {
@@ -313,11 +344,32 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}}
</ClientOnly>
<div
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 mb-6',
{
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
},
})}
>
<div className="bg-bolt-elements-background-depth-2">
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
</div>
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<div
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',
/*
* {
* 'sticky bottom-2': chatStarted,
* },
*/
)}
>
<svg className={classNames(styles.PromptEffectContainer)}>
@@ -347,6 +399,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
</svg>
<div>
<ClientOnly>
{() => (
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
<ModelSelector
key={provider?.name + ':' + modelList.length}
@@ -355,21 +409,22 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={providerList || PROVIDER_LIST}
providerList={providerList || (PROVIDER_LIST as ProviderInfo[])}
apiKeys={apiKeys}
modelLoading={isModelLoading}
/>
{(providerList || []).length > 0 && provider && (
{(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => {
const newApiKeys = { ...apiKeys, [provider.name]: key };
setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
onApiKeysChange(provider.name, key);
}}
/>
)}
</div>
)}
</ClientOnly>
</div>
<FilePreview
files={uploadedFiles}
@@ -528,15 +583,17 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
new line
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd>{' '}
+ <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd>{' '}
a new line
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center gap-5">
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
@@ -552,8 +609,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput);
})}
{!chatStarted && <StarterTemplates />}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>
<ClientOnly>
{() => (
<Workbench
actionRunner={actionRunner ?? ({} as ActionRunner)}
chatStarted={chatStarted}
isStreaming={isStreaming}
/>
)}
</ClientOnly>
</div>
</div>
);

View File

@@ -21,6 +21,11 @@ import { debounce } from '~/utils/debounce';
import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -34,6 +39,9 @@ export function Chat() {
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
const title = useStore(description);
useEffect(() => {
workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id));
}, [initialMessages]);
return (
<>
@@ -77,6 +85,24 @@ export function Chat() {
);
}
const processSampledMessages = createSampler(
(options: {
messages: Message[];
initialMessages: Message[];
isLoading: boolean;
parseMessages: (messages: Message[], isLoading: boolean) => void;
storeMessageHistory: (messages: Message[]) => Promise<void>;
}) => {
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
},
50,
);
interface ChatProps {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
@@ -91,11 +117,13 @@ export const ChatImpl = memo(
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const { activeProviders, promptId } = useSettings();
const actionAlert = useStore(workbenchStore.alert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
@@ -103,7 +131,7 @@ export const ChatImpl = memo(
});
const [provider, setProvider] = useState(() => {
const savedProvider = Cookies.get('selectedProvider');
return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
});
const { showChat } = useStore(chatStore);
@@ -112,27 +140,53 @@ export const ChatImpl = memo(
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
const {
messages,
isLoading,
input,
handleInputChange,
setInput,
stop,
append,
setMessages,
reload,
error,
data: chatData,
setData,
} = useChat({
api: '/api/chat',
body: {
apiKeys,
files,
promptId,
contextOptimization: contextOptimizationEnabled,
},
sendExtraMessageFields: true,
onError: (error) => {
logger.error('Request failed\n\n', error);
onError: (e) => {
logger.error('Request failed\n\n', e, error);
logStore.logError('Chat request failed', e, {
component: 'Chat',
action: 'request',
error: e.message,
});
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) => {
const usage = response.usage;
setData(undefined);
if (usage) {
console.log('Token usage:', usage);
// You can now use the usage data as needed
logStore.logProvider('Chat response completed', {
component: 'Chat',
action: 'response',
model,
provider: provider.name,
usage,
messageLength: message.content.length,
});
}
logger.debug('Finished streaming');
@@ -142,7 +196,8 @@ export const ChatImpl = memo(
});
useEffect(() => {
const prompt = searchParams.get('prompt');
console.log(prompt, searchParams, model, provider);
// console.log(prompt, searchParams, model, provider);
if (prompt) {
setSearchParams({});
@@ -169,11 +224,13 @@ export const ChatImpl = memo(
}, []);
useEffect(() => {
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
processSampledMessages({
messages,
initialMessages,
isLoading,
parseMessages,
storeMessageHistory,
});
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
@@ -188,6 +245,13 @@ export const ChatImpl = memo(
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
logStore.logProvider('Chat response aborted', {
component: 'Chat',
action: 'abort',
model,
provider: provider.name,
});
};
useEffect(() => {
@@ -219,53 +283,115 @@ export const ChatImpl = memo(
};
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;
}
/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* 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);
if (isLoading) {
abort();
return;
}
runAnimation();
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({
if (!chatStarted) {
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: `1-${new Date().getTime()}`,
role: 'user',
content: messageContent,
},
{
id: `2-${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `3-${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([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
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) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
] as any,
},
]);
reload();
setFakeLoading(false);
return;
}
if (error != null) {
setMessages(messages.slice(0, -1));
}
const modifiedFiles = workbenchStore.getModifiedFiles();
chatStore.setKey('aborted', false);
if (modifiedFiles !== undefined) {
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
});
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
append({
@@ -273,20 +399,19 @@ export const ChatImpl = memo(
content: [
{
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) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
] as any,
});
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
// Add file cleanup here
setUploadedFiles([]);
setImageDataList([]);
@@ -342,7 +467,10 @@ export const ChatImpl = memo(
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
isStreaming={isLoading || fakeLoading}
onStreamingChange={(streaming) => {
streamingState.set(streaming);
}}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
@@ -387,6 +515,9 @@ export const ChatImpl = memo(
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
data={chatData}
/>
);
},

View File

@@ -0,0 +1,108 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { ActionAlert } from '~/types/actions';
import { classNames } from '~/utils/classNames';
interface Props {
alert: ActionAlert;
clearAlert: () => void;
postMessage: (message: string) => void;
}
export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
const { description, content, source } = alert;
const isPreview = source === 'preview';
const title = isPreview ? 'Preview Error' : 'Terminal Error';
const message = isPreview
? 'We encountered an error while running the preview. Would you like Bolt to analyze and help resolve this issue?'
: 'We encountered an error while running terminal commands. Would you like Bolt to analyze and help resolve this issue?';
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
>
<div className="flex items-start">
{/* Icon */}
<motion.div
className="flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
<div className={`i-ph:warning-duotone text-xl text-bolt-elements-button-danger-text`}></div>
</motion.div>
{/* Content */}
<div className="ml-3 flex-1">
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`text-sm font-medium text-bolt-elements-textPrimary`}
>
{title}
</motion.h3>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
>
<p>{message}</p>
{description && (
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
Error: {description}
</div>
)}
</motion.div>
{/* Actions */}
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className={classNames(' flex gap-2')}>
<button
onClick={() =>
postMessage(
`*Fix this ${isPreview ? 'preview' : 'terminal'} error* \n\`\`\`${isPreview ? 'js' : 'sh'}\n${content}\n\`\`\`\n`,
)
}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-primary-background',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
'text-bolt-elements-button-primary-text',
'flex items-center gap-1.5',
)}
>
<div className="i-ph:chat-circle-duotone"></div>
Ask Bolt
</button>
<button
onClick={clearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-secondary-background',
'hover:bg-bolt-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
'text-bolt-elements-button-secondary-text',
)}
>
Dismiss
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,23 +1,26 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { detectProjectCommands, createCommandsMessage, escapeBoltTags } from '~/utils/projectCommands';
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 = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
@@ -30,54 +33,100 @@ const 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 {
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 onClick = async (_e: any) => {
const [loading, setLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleClone = async (repoUrl: string) => {
if (!ready) {
return;
}
const repoUrl = prompt('Enter the Git url');
setLoading(true);
if (repoUrl) {
try {
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
// Convert files to common format for command detection
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
return {
path: filePath,
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
};
})
.filter((f) => f.content);
let totalSize = 0;
const skippedFiles: string[] = [];
const fileContents = [];
for (const filePath of filePaths) {
const { data: content, encoding } = data[filePath];
// Skip binary files
if (
content instanceof Uint8Array &&
!filePath.match(/\.(txt|md|astro|mjs|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 commandsMessage = createCommandsMessage(commands);
// Create files message
const filesMessage: Message = {
role: 'assistant',
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">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
${escapeBoltTags(file.content)}
</boltAction>`,
)
.join('\n')}
@@ -94,17 +143,39 @@ ${file.content}
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 (
<button
onClick={onClick}
<>
<Button
onClick={() => setIsDialogOpen(true)}
title="Clone a Git Repo"
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',
className,
)}
disabled={!ready || loading}
>
<span className="i-ph:git-branch" />
<span className="i-ph:git-branch w-4 h-4" />
Clone a Git Repo
</button>
</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 { createChatFromFolder } from '~/utils/folderImport';
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 {
className?: string;
@@ -16,35 +18,40 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const allFiles = Array.from(e.target.files || []);
if (allFiles.length > MAX_FILES) {
const error = new Error(`Too many files: ${allFiles.length}`);
logStore.logError('File import failed - too many files', error, {
fileCount: allFiles.length,
maxFiles: MAX_FILES,
const filteredFiles = allFiles.filter((file) => {
const path = file.webkitRelativePath.split('/').slice(1).join('/');
const include = shouldIncludeFile(path);
return include;
});
toast.error(
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
);
return;
}
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
setIsLoading(true);
const loadingToast = toast.loading(`Importing ${folderName}...`);
try {
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
if (filteredFiles.length === 0) {
const error = new Error('No valid files found');
logStore.logError('File import failed - no valid files', error, { folderName });
logStore.logError('File import failed - no valid files', error, { folderName: 'Unknown Folder' });
toast.error('No files found in the selected folder');
return;
}
if (filteredFiles.length > MAX_FILES) {
const error = new Error(`Too many files: ${filteredFiles.length}`);
logStore.logError('File import failed - too many files', error, {
fileCount: filteredFiles.length,
maxFiles: MAX_FILES,
});
toast.error(
`This folder contains ${filteredFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
);
return;
}
const folderName = filteredFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
setIsLoading(true);
const loadingToast = toast.loading(`Importing ${folderName}...`);
try {
const fileChecks = await Promise.all(
filteredFiles.map(async (file) => ({
file,
@@ -107,17 +114,28 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
onChange={handleFileChange}
{...({} as any)}
/>
<button
<Button
onClick={() => {
const input = document.getElementById('folder-import');
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}
>
<div className="i-ph:upload-simple" />
<span className="i-ph:upload-simple w-4 h-4" />
{isLoading ? 'Importing...' : 'Import Folder'}
</button>
</Button>
</>
);
};

View File

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

View File

@@ -1,5 +1,5 @@
import type { Message } from 'ai';
import React from 'react';
import { Fragment } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
@@ -8,6 +8,10 @@ import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
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 {
id?: string;
@@ -16,9 +20,11 @@ interface MessagesProps {
messages?: Message[];
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const profile = useStore(profileStore);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -41,13 +47,18 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
};
return (
<div id={id} ref={ref} className={props.className}>
<div id={id} className={props.className} ref={ref}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId } = message;
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 <Fragment key={index} />;
}
return (
<div
@@ -60,8 +71,18 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
<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
src={profile.avatar}
alt={profile?.username || 'User'}
className="w-full h-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="i-ph:user-fill text-2xl" />
)}
</div>
)}
<div className="grid grid-col-1 w-full">
@@ -107,4 +128,5 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
)}
</div>
);
});
},
);

View File

@@ -1,6 +1,9 @@
import type { ProviderInfo } from '~/types/model';
import type { ModelInfo } from '~/utils/types';
import { useEffect } from 'react';
import { useEffect, useState, useRef } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
import * as React from 'react';
interface ModelSelectorProps {
model?: string;
@@ -10,6 +13,7 @@ interface ModelSelectorProps {
modelList: ModelInfo[];
providerList: ProviderInfo[];
apiKeys: Record<string, string>;
modelLoading?: string;
}
export const ModelSelector = ({
@@ -19,13 +23,120 @@ export const ModelSelector = ({
setProvider,
modelList,
providerList,
modelLoading,
}: 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
useEffect(() => {
// If current provider is disabled, switch to first enabled provider
if (providerList.length == 0) {
if (providerList.length === 0) {
return;
}
@@ -78,20 +189,124 @@ export const ModelSelector = ({
</option>
))}
</select>
<select
key={provider?.name}
value={model}
onChange={(e) => setModel?.(e.target.value)}
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%]"
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
<div
className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
'transition-all cursor-pointer',
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
)}
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsModelDropdownOpen(!isModelDropdownOpen);
}
}}
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}
>
{[...modelList]
.filter((e) => e.provider == provider?.name && e.name)
.map((modelOption, index) => (
<option key={index} value={modelOption.name}>
{modelOption.label}
</option>
))}
</select>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { useStore } from '@nanostores/react';
import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify';
import { chatId } from '~/lib/persistence/useChatHistory';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect } from 'react';
export function NetlifyDeploymentLink() {
const connection = useStore(netlifyConnection);
const currentChatId = useStore(chatId);
useEffect(() => {
if (connection.token && currentChatId) {
fetchNetlifyStats(connection.token);
}
}, [connection.token, currentChatId]);
const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`));
if (!deployedSite) {
return null;
}
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deployedSite.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
onClick={(e) => {
e.stopPropagation(); // Add this to prevent click from bubbling up
}}
>
<div className="i-ph:rocket-launch w-5 h-5" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deployedSite.url}
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,110 @@
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',
)}
>
<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

@@ -0,0 +1,46 @@
import React from 'react';
import type { Template } from '~/types/template';
import { STARTER_TEMPLATES } from '~/utils/constants';
interface FrameworkLinkProps {
template: Template;
}
const FrameworkLink: React.FC<FrameworkLinkProps> = ({ template }) => (
<a
href={`/git?url=https://github.com/${template.githubRepo}.git`}
data-state="closed"
data-discover="true"
className="items-center justify-center"
>
<div
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>
);
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 (
<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>
<div className="flex justify-center">
<div className="flex w-70 flex-wrap items-center justify-center gap-4">
{STARTER_TEMPLATES.map((template) => (
<FrameworkLink key={template.name} template={template} />
))}
</div>
</div>
</div>
);
};
export default StarterTemplates;

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

@@ -43,5 +43,6 @@ export function UserMessage({ content }: UserMessageProps) {
}
function stripMetadata(content: string) {
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
const artifactRegex = /<boltArtifact\s+[^>]*>[\s\S]*?<\/boltArtifact>/gm;
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
}

View File

@@ -1,6 +1,13 @@
import type { Message } from 'ai';
import { toast } from 'react-toastify';
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) {
return (
@@ -20,14 +27,17 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const data = JSON.parse(content);
const data = JSON.parse(content) as ChatData;
if (!Array.isArray(data.messages)) {
toast.error('Invalid chat file format');
// Standard 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.success('Chat imported successfully');
toast.error('Invalid chat file format');
} catch (error: unknown) {
if (error instanceof Error) {
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 gap-2">
<button
<Button
onClick={() => {
const input = document.getElementById('chat-import');
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
</button>
</Button>
<ImportFolderButton
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>

View File

@@ -7,7 +7,7 @@ import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { useGit } from '~/lib/hooks/useGit';
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 { toast } from 'react-toastify';
@@ -49,29 +49,28 @@ export function GitUrlImport() {
if (repoUrl) {
const ig = ignore().add(IGNORE_PATTERNS);
try {
const { workdir, data } = await gitClone(repoUrl);
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
.map((filePath) => {
const { data: content, encoding } = data[filePath];
return {
path: filePath,
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
content:
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
};
})
.filter((f) => f.content);
// Detect and create commands message
const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands);
// Create files message
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
@@ -80,7 +79,7 @@ ${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
${escapeBoltTags(file.content)}
</boltAction>`,
)
.join('\n')}
@@ -92,10 +91,23 @@ ${file.content}
const messages = [filesMessage];
if (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);
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 = '/';
return;
}
}
};

View File

@@ -1,21 +1,281 @@
import { useStore } from '@nanostores/react';
import { toast } from 'react-toastify';
import useViewport from '~/lib/hooks';
import { chatStore } from '~/lib/stores/chat';
import { netlifyConnection } from '~/lib/stores/netlify';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { classNames } from '~/utils/classNames';
import { path } from '~/utils/path';
import { useEffect, useRef, useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
import { streamingState } from '~/lib/stores/streaming';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
interface HeaderActionButtonsProps {}
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const connection = useStore(netlifyConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const isSmallViewport = useViewport(1024);
const canHideChat = showWorkbench || !showChat;
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const isStreaming = useStore(streamingState);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const currentChatId = useStore(chatId);
const handleDeploy = async () => {
if (!connection.user || !connection.token) {
toast.error('Please connect to Netlify first in the settings tab!');
return;
}
if (!currentChatId) {
toast.error('No active chat found');
return;
}
try {
setIsDeploying(true);
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'netlify build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: 'npm run build',
},
};
// Add the action first
artifact.runner.addAction(actionData);
// Then run it
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
throw new Error('Build failed');
}
// Get the build files
const container = await webcontainer;
// Remove /home/project from buildPath if it exists
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
// Get all files recursively
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) {
const content = await container.fs.readFile(fullPath, 'utf-8');
// Remove /dist prefix from the path
const deployPath = fullPath.replace(buildPath, '');
files[deployPath] = content;
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles(buildPath);
// Use chatId instead of artifact.id
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
// Deploy using the API route with file contents
const response = await fetch('/api/deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
siteId: existingSiteId || undefined,
files: fileContents,
token: connection.token,
chatId: currentChatId, // Use chatId instead of artifact.id
}),
});
const data = (await response.json()) as any;
if (!response.ok || !data.deploy || !data.site) {
console.error('Invalid deploy response:', data);
throw new Error(data.error || 'Invalid deployment response');
}
// Poll for deployment status
const maxAttempts = 20; // 2 minutes timeout
let attempts = 0;
let deploymentStatus;
while (attempts < maxAttempts) {
try {
const statusResponse = await fetch(
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
},
},
);
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
break;
}
if (deploymentStatus.state === 'error') {
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
}
attempts++;
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error('Status check error:', error);
attempts++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (attempts >= maxAttempts) {
throw new Error('Deployment timed out');
}
// Store the site ID if it's a new site
if (data.site) {
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
}
toast.success(
<div>
Deployed successfully!{' '}
<a
href={deploymentStatus.ssl_url || deploymentStatus.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View site
</a>
</div>,
);
} catch (error) {
console.error('Deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Deployment failed');
} finally {
setIsDeploying(false);
}
};
return (
<div className="flex">
<div className="relative" ref={dropdownRef}>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<Button
active
disabled={isDeploying || !activePreview || isStreaming}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
>
{isDeploying ? 'Deploying...' : 'Deploy'}
<div
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
/>
</Button>
</div>
{isDropdownOpen && (
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[13.5rem] bg-bolt-elements-background-depth-2 rounded-md shadow-lg bg-bolt-elements-backgroundDefault border border-bolt-elements-borderColor">
<Button
active
onClick={() => {
handleDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !connection.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
{connection.user && <NetlifyDeploymentLink />}
</Button>
<Button
active={false}
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
>
<span className="sr-only">Coming Soon</span>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
</Button>
<Button
active={false}
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
>
<span className="sr-only">Coming Soon</span>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="vercel"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</Button>
</div>
)}
</div>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
<Button
active={showChat}
@@ -51,18 +311,23 @@ interface ButtonProps {
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
className?: string;
}
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return (
<button
className={classNames('flex items-center p-1.5', {
className={classNames(
'flex items-center p-1.5',
{
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
disabled,
})}
},
className,
)}
onClick={onClick}
>
{children}

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 ChatHistoryTab from './chat-history/ChatHistoryTab';
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';
interface SettingsProps {
open: boolean;
onClose: () => void;
}
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
const { debug, eventLogs } = useSettings();
const [activeTab, setActiveTab] = useState<TabType>('chat-history');
const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
{ id: 'chat-history', label: 'Chat History', icon: 'i-ph:book', component: <ChatHistoryTab /> },
{ 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 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,119 +0,0 @@
import { useNavigate } from '@remix-run/react';
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { db, deleteById, getAll } from '~/lib/persistence';
import { classNames } from '~/utils/classNames';
import styles from '~/components/settings/Settings.module.scss';
import { logStore } from '~/lib/stores/logs'; // Import logStore for event logging
export default function ChatHistoryTab() {
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 handleDeleteAllChats = async () => {
const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
if (!confirmDelete) {
return; // Exit if the user cancels
}
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 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);
}
};
return (
<>
<div className="p-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Chat History</h3>
<button
onClick={handleExportAllChats}
className={classNames(
'bg-bolt-elements-button-primary-background',
'rounded-lg px-4 py-2 mb-4 transition-colors duration-200',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'text-bolt-elements-button-primary-text',
)}
>
Export All Chats
</button>
<div
className={classNames('text-bolt-elements-textPrimary rounded-lg py-4 mb-4', styles['settings-danger-area'])}
>
<h4 className="font-semibold">Danger Area</h4>
<p className="mb-2">This action cannot be undone!</p>
<button
onClick={handleDeleteAllChats}
disabled={isDeleting}
className={classNames(
'bg-bolt-elements-button-danger-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
isDeleting ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bolt-elements-button-danger-backgroundHover',
'text-bolt-elements-button-danger-text',
)}
>
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
</button>
</div>
</div>
</>
);
}

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,620 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import commit from '~/commit.json';
import { toast } from 'react-toastify';
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;
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: (branch: string) =>
`https://raw.githubusercontent.com/stackblitz-labs/bolt.diy/${branch}/app/commit.json`,
};
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}/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 =
provider.name.toLowerCase() === 'ollama'
? 'OLLAMA_API_BASE_URL'
: provider.name.toLowerCase() === 'lmstudio'
? 'LMSTUDIO_API_BASE_URL'
: `REACT_APP_${provider.name.toUpperCase()}_URL`;
// Access environment variables through import.meta.env
const url = import.meta.env[envVarName] || provider.settings.baseUrl || 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 localCommitResponse = await fetch(GITHUB_URLS.commitJson(branchToCheck));
if (!localCommitResponse.ok) {
throw new Error('Failed to fetch local commit info');
}
const localCommitData = (await localCommitResponse.json()) as CommitData;
const remoteCommitHash = localCommitData.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">
{versionHash.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,77 +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,
} = 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-sm text-bolt-elements-textSecondary">
Check for updates against the main branch instead of stable
</p>
</div>
<Switch className="ml-auto" checked={isLatestBranch} onCheckedChange={enableLatestBranch} />
</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-4">
Disclaimer: Experimental features may be unstable and are subject to change.
</p>
<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>
<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-sm text-bolt-elements-textSecondary 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,106 +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 DefaultIcon from '/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));
setFilteredProviders(newFilteredProviders);
}, [providers, searchTerm, isLocalModel]);
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>
{filteredProviders.map((provider) => (
<div
key={provider.name}
className="flex flex-col mb-2 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`} // Attempt to load the specific icon
onError={(e) => {
// Fallback to default icon on error
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>
{/* Base URL input for configurable providers */}
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.settings.enabled && (
<div className="mt-2">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Base URL:</label>
<input
type="text"
value={provider.settings.baseUrl || ''}
onChange={(e) => {
const newBaseUrl = e.target.value;
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>
))}
</div>
);
}

View File

@@ -24,11 +24,18 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
syncWithGlobalStore: isActiveChat,
});
const renderDescriptionForm = (
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
return (
<div
className={classNames(
'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-gray-900 dark:text-white bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
)}
>
{editing ? (
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
<input
type="text"
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
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}
@@ -37,34 +44,25 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
/>
<button
type="submit"
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
className="i-ph:check h-4 w-4 text-gray-500 hover:text-purple-500 transition-colors"
onMouseDown={handleSubmit}
/>
</form>
);
return (
<div
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',
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
)}
>
{editing ? (
renderDescriptionForm
) : (
<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
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%',
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
'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',
{ '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
toolTipContent="Export chat"
icon="i-ph:download-simple"
toolTipContent="Export"
icon="i-ph:download-simple h-4 w-4"
onClick={(event) => {
event.preventDefault();
exportChat(item.id);
@@ -72,14 +70,14 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
/>
{onDuplicate && (
<ChatActionButton
toolTipContent="Duplicate chat"
icon="i-ph:copy"
toolTipContent="Duplicate"
icon="i-ph:copy h-4 w-4"
onClick={() => onDuplicate?.(item.id)}
/>
)}
<ChatActionButton
toolTipContent="Rename chat"
icon="i-ph:pencil-fill"
toolTipContent="Rename"
icon="i-ph:pencil-fill h-4 w-4"
onClick={(event) => {
event.preventDefault();
toggleEditMode();
@@ -87,9 +85,9 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
/>
<Dialog.Trigger asChild>
<ChatActionButton
toolTipContent="Delete chat"
icon="i-ph:trash"
className="[&&]:hover:text-bolt-elements-button-danger-text"
toolTipContent="Delete"
icon="i-ph:trash h-4 w-4"
className="hover:text-red-500"
onClick={(event) => {
event.preventDefault();
onDelete?.(event);
@@ -121,11 +119,11 @@ const ChatActionButton = forwardRef(
ref: ForwardedRef<HTMLButtonElement>,
) => {
return (
<WithTooltip tooltip={toolTipContent}>
<WithTooltip tooltip={toolTipContent} position="bottom" sideOffset={4}>
<button
ref={ref}
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}
/>
</WithTooltip>

View File

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

View File

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

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