Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"
This reverts commit e68593f22d.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,4 +46,3 @@ docs/instructions/Roadmap.md
|
||||
.cursorrules
|
||||
*.md
|
||||
.qodo
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
# 🚀 bolt.diy Multi-User System Documentation
|
||||
|
||||
**Developer: Keoma Wright**
|
||||
**Version: 1.0.0**
|
||||
**Date: 27 August 2025**
|
||||
|
||||
## 📋 Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Features](#features)
|
||||
4. [Installation & Setup](#installation--setup)
|
||||
5. [User Guide](#user-guide)
|
||||
6. [Admin Guide](#admin-guide)
|
||||
7. [Security](#security)
|
||||
8. [API Reference](#api-reference)
|
||||
9. [Technical Details](#technical-details)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Overview
|
||||
|
||||
The bolt.diy Multi-User System transforms the single-user bolt.diy application into a comprehensive multi-user platform with isolated workspaces, personalized settings, and robust user management - all without requiring a traditional database.
|
||||
|
||||
### Key Highlights
|
||||
- ✅ **No Database Required** - File-based storage system
|
||||
- ✅ **Isolated Workspaces** - Each user has their own chat history and projects
|
||||
- ✅ **Beautiful UI** - Stunning login/signup pages with glassmorphism design
|
||||
- ✅ **Avatar Support** - Users can upload custom avatars
|
||||
- ✅ **Admin Panel** - Comprehensive user management interface
|
||||
- ✅ **Security** - JWT authentication with bcrypt password hashing
|
||||
- ✅ **Personalized Experience** - Custom greetings and user preferences
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Authentication Pages │ Protected Routes │
|
||||
│ - Login/Signup │ - Chat Interface │
|
||||
│ - Avatar Upload │ - User Management │
|
||||
│ │ - Settings │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Authentication Layer │
|
||||
│ - JWT Tokens │ - Session Management │
|
||||
│ - Auth Store │ - Protected HOCs │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
│ File-Based Storage │ User-Specific DBs │
|
||||
│ - .users/ │ - IndexedDB per user │
|
||||
│ - Security logs │ - Isolated workspaces │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
/root/bolt/
|
||||
├── .users/ # User data directory (secured)
|
||||
│ ├── users.json # User registry
|
||||
│ ├── security.log # Security audit logs
|
||||
│ └── data/ # User-specific data
|
||||
│ └── {userId}/ # Individual user directories
|
||||
├── app/
|
||||
│ ├── components/
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── LoginForm.tsx
|
||||
│ │ │ ├── SignupForm.tsx
|
||||
│ │ │ └── ProtectedRoute.tsx
|
||||
│ │ ├── chat/
|
||||
│ │ │ ├── AuthenticatedChat.tsx
|
||||
│ │ │ └── WelcomeMessage.tsx
|
||||
│ │ ├── header/
|
||||
│ │ │ └── UserMenu.tsx
|
||||
│ │ └── admin/
|
||||
│ │ └── UserManager.tsx
|
||||
│ ├── lib/
|
||||
│ │ ├── stores/
|
||||
│ │ │ └── auth.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── crypto.ts
|
||||
│ │ │ └── fileUserStorage.ts
|
||||
│ │ └── persistence/
|
||||
│ │ └── userDb.ts
|
||||
│ └── routes/
|
||||
│ ├── auth.tsx
|
||||
│ ├── admin.users.tsx
|
||||
│ └── api.auth.*.ts
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 🔐 Authentication System
|
||||
|
||||
#### Login Page
|
||||
- Beautiful gradient animated background
|
||||
- Glassmorphism card design
|
||||
- Remember me functionality (7-day sessions)
|
||||
- Smooth tab transitions between login/signup
|
||||
- Real-time validation feedback
|
||||
|
||||
#### Signup Page
|
||||
- Avatar upload with preview
|
||||
- Password strength indicator
|
||||
- First name for personalization
|
||||
- Username validation
|
||||
- Animated form transitions
|
||||
|
||||
### 👤 User Management
|
||||
|
||||
#### User Profile
|
||||
- Unique user ID generation
|
||||
- Avatar storage as base64
|
||||
- Preferences storage
|
||||
- Last login tracking
|
||||
- Creation date tracking
|
||||
|
||||
#### Admin Panel
|
||||
- User grid with search
|
||||
- User statistics dashboard
|
||||
- Delete user with confirmation
|
||||
- Edit user capabilities
|
||||
- Activity monitoring
|
||||
|
||||
### 💬 Personalized Chat Experience
|
||||
|
||||
#### Welcome Message
|
||||
- Personalized greeting: "{First Name}, What would you like to build today?"
|
||||
- Time-based greetings (morning/afternoon/evening)
|
||||
- User statistics display
|
||||
- Example prompts
|
||||
|
||||
#### Chat History Isolation
|
||||
- User-specific IndexedDB
|
||||
- Isolated chat sessions
|
||||
- Personal workspace files
|
||||
- Settings per user
|
||||
|
||||
### 🎨 UI/UX Enhancements
|
||||
|
||||
#### Design Elements
|
||||
- Glassmorphism effects
|
||||
- Animated gradients
|
||||
- Smooth transitions (Framer Motion)
|
||||
- Dark/light theme support
|
||||
- Responsive design
|
||||
|
||||
#### User Menu
|
||||
- Avatar display
|
||||
- Quick access to settings
|
||||
- User management link
|
||||
- Sign out option
|
||||
- Member since date
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
# Required packages
|
||||
pnpm add bcryptjs jsonwebtoken
|
||||
pnpm add -D @types/bcryptjs @types/jsonwebtoken
|
||||
```
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. **Create user directory**
|
||||
```bash
|
||||
mkdir -p .users
|
||||
chmod 700 .users
|
||||
```
|
||||
|
||||
2. **Environment Variables**
|
||||
```env
|
||||
JWT_SECRET=your-secure-secret-key-here
|
||||
```
|
||||
|
||||
3. **Start the application**
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
Navigate to `http://localhost:5173/auth` to create your first account.
|
||||
|
||||
## User Guide
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Create an Account**
|
||||
- Navigate to `/auth`
|
||||
- Click "Sign Up" tab
|
||||
- Upload an avatar (optional)
|
||||
- Enter your details
|
||||
- Create a strong password
|
||||
|
||||
2. **Login**
|
||||
- Enter username and password
|
||||
- Check "Remember me" for persistent sessions
|
||||
- Click "Sign In"
|
||||
|
||||
3. **Using the Chat**
|
||||
- Personalized greeting appears
|
||||
- Your chat history is private
|
||||
- Settings are saved per user
|
||||
|
||||
4. **Managing Your Profile**
|
||||
- Click your avatar in the header
|
||||
- Access settings
|
||||
- View member information
|
||||
|
||||
## Admin Guide
|
||||
|
||||
### User Management
|
||||
|
||||
1. **Access Admin Panel**
|
||||
- Click user menu → "Manage Users"
|
||||
- Or navigate to `/admin/users`
|
||||
|
||||
2. **View Users**
|
||||
- See all registered users
|
||||
- View statistics
|
||||
- Search and filter
|
||||
|
||||
3. **Delete Users**
|
||||
- Click trash icon
|
||||
- Confirm deletion
|
||||
- User data is permanently removed
|
||||
|
||||
4. **Monitor Activity**
|
||||
- Check security logs
|
||||
- View last login times
|
||||
- Track user creation
|
||||
|
||||
### Security Logs
|
||||
|
||||
Security events are logged to `.users/security.log`:
|
||||
- Login attempts (successful/failed)
|
||||
- User creation
|
||||
- User deletion
|
||||
- Errors
|
||||
|
||||
Example log entry:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-12-27T10:30:45.123Z",
|
||||
"userId": "user_123456_abc",
|
||||
"username": "john_doe",
|
||||
"action": "login",
|
||||
"details": "Successful login",
|
||||
"ip": "192.168.1.1"
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Password Security
|
||||
- **Bcrypt hashing** with salt rounds
|
||||
- **Complexity requirements**:
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
|
||||
### Session Management
|
||||
- **JWT tokens** with expiration
|
||||
- **7-day session** option
|
||||
- **Automatic logout** on expiration
|
||||
- **Secure cookie storage**
|
||||
|
||||
### File Permissions
|
||||
- `.users/` directory: `700` (owner only)
|
||||
- User data files: JSON format
|
||||
- Security logs: Append-only
|
||||
|
||||
### Best Practices
|
||||
- Never store plain passwords
|
||||
- Use environment variables for secrets
|
||||
- Regular security log reviews
|
||||
- Implement rate limiting (future)
|
||||
|
||||
## API Reference
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
#### POST `/api/auth/login`
|
||||
```typescript
|
||||
Request: {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
Response: {
|
||||
success: boolean;
|
||||
user?: UserProfile;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/auth/signup`
|
||||
```typescript
|
||||
Request: {
|
||||
username: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
Response: {
|
||||
success: boolean;
|
||||
user?: UserProfile;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/auth/logout`
|
||||
```typescript
|
||||
Headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
|
||||
Response: {
|
||||
success: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/auth/verify`
|
||||
```typescript
|
||||
Headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
|
||||
Response: {
|
||||
success: boolean;
|
||||
user?: UserProfile;
|
||||
}
|
||||
```
|
||||
|
||||
### User Management Endpoints
|
||||
|
||||
#### GET `/api/users`
|
||||
Get all users (requires authentication)
|
||||
|
||||
#### DELETE `/api/users/:id`
|
||||
Delete a specific user (requires authentication)
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Storage System
|
||||
|
||||
#### User Registry (`users.json`)
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "user_123456_abc",
|
||||
"username": "john_doe",
|
||||
"firstName": "John",
|
||||
"passwordHash": "$2a$10$...",
|
||||
"avatar": "data:image/png;base64,...",
|
||||
"createdAt": "2024-12-27T10:00:00.000Z",
|
||||
"lastLogin": "2024-12-27T15:30:00.000Z",
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"deploySettings": {},
|
||||
"githubSettings": {},
|
||||
"workspaceConfig": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### User-Specific IndexedDB
|
||||
Each user has their own database: `boltHistory_{userId}`
|
||||
- Chats store
|
||||
- Snapshots store
|
||||
- Settings store
|
||||
- Workspaces store
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
User->>Frontend: Enter credentials
|
||||
Frontend->>API: POST /api/auth/login
|
||||
API->>FileStorage: Verify user
|
||||
API->>Crypto: Verify password
|
||||
API->>Crypto: Generate JWT
|
||||
API->>SecurityLog: Log attempt
|
||||
API->>Frontend: Return token + user
|
||||
Frontend->>AuthStore: Save state
|
||||
Frontend->>Cookie: Store token
|
||||
Frontend->>Chat: Redirect to chat
|
||||
```
|
||||
|
||||
### Workspace Isolation
|
||||
|
||||
Each user's workspace is completely isolated:
|
||||
1. **Chat History** - Stored in user-specific IndexedDB
|
||||
2. **Settings** - LocalStorage with user prefix
|
||||
3. **Files** - Virtual file system per user
|
||||
4. **Deploy Settings** - User-specific configurations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Cannot Login
|
||||
- Verify username/password
|
||||
- Check security logs
|
||||
- Ensure `.users/` directory exists
|
||||
|
||||
#### Session Expired
|
||||
- Re-login required
|
||||
- Use "Remember me" for longer sessions
|
||||
|
||||
#### User Data Not Loading
|
||||
- Check browser IndexedDB
|
||||
- Verify user ID in auth store
|
||||
- Clear browser cache if needed
|
||||
|
||||
#### Avatar Not Displaying
|
||||
- Check file size (max 2MB recommended)
|
||||
- Verify base64 encoding
|
||||
- Test with different image formats
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
```javascript
|
||||
// In browser console
|
||||
localStorage.setItem('DEBUG', 'true');
|
||||
```
|
||||
|
||||
View security logs:
|
||||
```bash
|
||||
tail -f .users/security.log
|
||||
```
|
||||
|
||||
### Recovery
|
||||
|
||||
#### Reset User Password
|
||||
Currently requires manual intervention:
|
||||
1. Generate new hash using bcrypt
|
||||
2. Update users.json
|
||||
3. Restart application
|
||||
|
||||
#### Restore Deleted User
|
||||
If backup exists:
|
||||
1. Restore from users.json backup
|
||||
2. Recreate user data directory
|
||||
3. Restore IndexedDB if available
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- [ ] Password reset via email
|
||||
- [ ] Two-factor authentication
|
||||
- [ ] User roles and permissions
|
||||
- [ ] Team workspaces
|
||||
- [ ] Usage analytics
|
||||
- [ ] Export/import user data
|
||||
- [ ] Social login integration
|
||||
- [ ] Rate limiting
|
||||
- [ ] Session management UI
|
||||
- [ ] Audit trail viewer
|
||||
|
||||
### Performance Optimizations
|
||||
- [ ] Database indexing strategies
|
||||
- [ ] Lazy loading user data
|
||||
- [ ] Caching layer
|
||||
- [ ] CDN for avatars
|
||||
|
||||
## Contributing
|
||||
|
||||
This system was developed by **Keoma Wright** as an enhancement to the bolt.diy project.
|
||||
|
||||
### Development Guidelines
|
||||
1. Maintain backward compatibility
|
||||
2. Follow existing code patterns
|
||||
3. Add tests for new features
|
||||
4. Update documentation
|
||||
5. Consider security implications
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This multi-user system is an extension of the bolt.diy project and follows the same license terms.
|
||||
|
||||
## Credits
|
||||
|
||||
**Developer**: Keoma Wright
|
||||
**Project**: bolt.diy Multi-User Edition
|
||||
**Year**: 2025
|
||||
|
||||
---
|
||||
|
||||
*This documentation provides a comprehensive guide to the bolt.diy Multi-User System. For questions or issues, please contact the developer or submit an issue to the repository.*
|
||||
603
README.md
603
README.md
@@ -1,212 +1,493 @@
|
||||
# Bolt.gives - Enhanced AI Development Platform
|
||||
# bolt.diy
|
||||
|
||||

|
||||
[](https://bolt.diy)
|
||||
|
||||
## 🚀 About This Fork
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new, 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, Groq, Cohere, Together, Perplexity, Moonshot (Kimi), Hyperbolic, GitHub Models, Amazon Bedrock, and OpenAI-like providers - 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.
|
||||
|
||||
**Bolt.gives** is an advanced fork of bolt.diy that takes AI-powered development to the next level. While maintaining compatibility with the original, we're developing in a different direction focused on enterprise features, enhanced deployment capabilities, and multi-user collaboration.
|
||||
-----
|
||||
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more official installation instructions and additional information.
|
||||
|
||||
### 🎯 Our Vision
|
||||
-----
|
||||
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 believe in making AI development accessible to everyone while providing professional-grade features for teams and enterprises. Bolt.gives is evolving beyond a simple development tool into a comprehensive platform for collaborative AI-assisted development.
|
||||
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/).
|
||||
|
||||
## 🌟 Exclusive Features Not Available in bolt.diy
|
||||
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!
|
||||
|
||||
Our fork includes numerous advanced features that were submitted as PRs to bolt.diy but were not integrated into the main application:
|
||||
## Table of Contents
|
||||
|
||||
### 🤖 **NEW: SmartAI - Detailed Conversational Coding (Bolt.gives Exclusive)**
|
||||
- **Real-time explanations** of what the AI is doing and why
|
||||
- **Educational feedback** that helps you learn as you code
|
||||
- **Step-by-step narration** of the coding process
|
||||
- **Best practices insights** shared during implementation
|
||||
- **Debugging explanations** showing how issues are identified and fixed
|
||||
- **Available for Claude Sonnet 4** with more models coming soon
|
||||
- Transform "Generating Response..." into an interactive coding companion!
|
||||
- [Join the Community](#join-the-community)
|
||||
- [Recent Major Additions](#recent-major-additions)
|
||||
- [Features](#features)
|
||||
- [Setup](#setup)
|
||||
- [Quick Installation](#quick-installation)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [Configuring API Keys and Providers](#configuring-api-keys-and-providers)
|
||||
- [Setup Using Git (For Developers only)](#setup-using-git-for-developers-only)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [Contributing](#contributing)
|
||||
- [Roadmap](#roadmap)
|
||||
- [FAQ](#faq)
|
||||
|
||||
### ✨ **Comprehensive Save All System**
|
||||
- **One-click save** for all modified files
|
||||
- **Auto-save functionality** with customizable intervals
|
||||
- **Visual indicators** showing file modification status
|
||||
- **Keyboard shortcuts** (Ctrl/Cmd+S) for quick saving
|
||||
- **Smart file tracking** with modification timestamps
|
||||
- Never lose your work again!
|
||||
## Join the community
|
||||
|
||||
### 🚀 **Advanced Import Capabilities**
|
||||
- **Import existing projects** from local folders
|
||||
- **GitHub template integration** for quick starts
|
||||
- **Automatic dependency detection**
|
||||
- **File structure preservation**
|
||||
- **Support for complex project hierarchies**
|
||||
- Seamlessly migrate your existing projects
|
||||
[Join the bolt.diy community here, in the oTTomator Think Tank!](https://thinktank.ottomator.ai)
|
||||
|
||||
### 🔐 **Multi-User Authentication System**
|
||||
- **User registration and login**
|
||||
- **Workspace isolation** for security
|
||||
- **Personalized settings** per user
|
||||
- **File-based secure storage**
|
||||
- **JWT authentication**
|
||||
- **Optional guest mode** for quick access
|
||||
- Perfect for teams and organizations
|
||||
## Project management
|
||||
|
||||
### 📦 **Quick Deploy to Netlify**
|
||||
- **One-click Netlify deployment**
|
||||
- **Automatic build configuration**
|
||||
- **Environment variable management**
|
||||
- **Site preview functionality**
|
||||
- **Custom domain support**
|
||||
- Deploy your projects in seconds
|
||||
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.
|
||||
|
||||
### 🤖 **Extended AI Model Support**
|
||||
- **Claude 4 models** (Opus, Sonnet, Haiku)
|
||||
- **Claude Sonnet 4 (SmartAI)** - Exclusive conversational coding variant
|
||||
- **Auto-detection of Ollama** when configured
|
||||
- **Enhanced provider management**
|
||||
- **Automatic provider enablement** based on environment
|
||||
- Access to the latest and most powerful AI models
|
||||
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.
|
||||
|
||||
### 🎨 **Enhanced UI/UX Features**
|
||||
- **Project import from folders**
|
||||
- **GitHub template integration**
|
||||
- **Advanced model selector** with provider filtering
|
||||
- **Improved error handling** and user feedback
|
||||
- **Responsive design improvements**
|
||||
- **Theme customization options**
|
||||
- A more intuitive development experience
|
||||
## Recent Major Additions
|
||||
|
||||
### 🔧 **Developer-Focused Improvements**
|
||||
- **Better TypeScript support**
|
||||
- **Enhanced debugging tools**
|
||||
- **Improved console output**
|
||||
- **Advanced file management**
|
||||
- **Git integration enhancements**
|
||||
- **Terminal improvements**
|
||||
- Tools built by developers, for developers
|
||||
### ✅ Completed Features
|
||||
- **19+ AI Provider Integrations** - OpenAI, Anthropic, Google, Groq, xAI, DeepSeek, Mistral, Cohere, Together, Perplexity, HuggingFace, Ollama, LM Studio, OpenRouter, Moonshot, Hyperbolic, GitHub Models, Amazon Bedrock, OpenAI-like
|
||||
- **Electron Desktop App** - Native desktop experience with full functionality
|
||||
- **Advanced Deployment Options** - Netlify, Vercel, and GitHub Pages deployment
|
||||
- **Supabase Integration** - Database management and query capabilities
|
||||
- **Data Visualization & Analysis** - Charts, graphs, and data analysis tools
|
||||
- **MCP (Model Context Protocol)** - Enhanced AI tool integration
|
||||
- **Search Functionality** - Codebase search and navigation
|
||||
- **File Locking System** - Prevents conflicts during AI code generation
|
||||
- **Diff View** - Visual representation of AI-made changes
|
||||
- **Git Integration** - Clone, import, and deployment capabilities
|
||||
- **Expo App Creation** - React Native development support
|
||||
- **Voice Prompting** - Audio input for prompts
|
||||
- **Bulk Chat Operations** - Delete multiple chats at once
|
||||
- **Project Snapshot Restoration** - Restore projects from snapshots on reload
|
||||
|
||||
## 💰 Coming Soon: Hosted Instances
|
||||
### 🔄 In Progress / Planned
|
||||
- **File Locking & Diff Improvements** - Enhanced conflict prevention
|
||||
- **Backend Agent Architecture** - Move from single model calls to agent-based system
|
||||
- **LLM Prompt Optimization** - Better performance for smaller models
|
||||
- **Project Planning Documentation** - LLM-generated project plans in markdown
|
||||
- **VSCode Integration** - Git-like confirmations and workflows
|
||||
- **Document Upload for Knowledge** - Reference materials and coding style guides
|
||||
- **Additional Provider Integrations** - Azure OpenAI, Vertex AI, Granite
|
||||
|
||||
We will be offering **Hosted Bolt.gives Instances** starting from **$5 per month** for a basic instance with the ability for donors to upgrade their resources. This will provide:
|
||||
- Dedicated cloud environment
|
||||
- No setup required
|
||||
- Automatic updates
|
||||
- Priority support
|
||||
- Custom domain options
|
||||
- Team collaboration features
|
||||
## Features
|
||||
|
||||
## 🚀 Latest Updates from bolt.diy
|
||||
- **AI-powered full-stack web development** for **NodeJS based applications** directly in your browser.
|
||||
- **Support for 19+ 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.
|
||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
||||
- **Download projects as ZIP** for easy portability and sync to a folder on the host.
|
||||
- **Integration-ready Docker support** for a hassle-free setup.
|
||||
- **Deploy directly** to **Netlify**, **Vercel**, or **GitHub Pages**.
|
||||
- **Electron desktop app** for native desktop experience.
|
||||
- **Data visualization and analysis** with integrated charts and graphs.
|
||||
- **Git integration** with clone, import, and deployment capabilities.
|
||||
- **MCP (Model Context Protocol)** support for enhanced AI tool integration.
|
||||
- **Search functionality** to search through your codebase.
|
||||
- **File locking system** to prevent conflicts during AI code generation.
|
||||
- **Diff view** to see changes made by the AI.
|
||||
- **Supabase integration** for database management and queries.
|
||||
- **Expo app creation** for React Native development.
|
||||
|
||||
We regularly merge the latest improvements from bolt.diy including:
|
||||
## Setup
|
||||
|
||||
### Recent Merged Features:
|
||||
- **GitLab Integration** - Full GitLab support alongside GitHub
|
||||
- **Branch Cloning Support** - Clone specific branches from repositories
|
||||
- **Token Limit Fixes** - Improved handling of model token limits
|
||||
- **Documentation Updates** - Enhanced help and documentation
|
||||
- **UI Improvements** - Better header layout and help accessibility
|
||||
- **Bug Fixes** - Various stability and performance improvements
|
||||
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.
|
||||
|
||||
## 📊 Feature Comparison
|
||||
Let's get you up and running with the stable version of Bolt.DIY!
|
||||
|
||||
| Feature | bolt.diy | Bolt.gives |
|
||||
|---------|----------|------------|
|
||||
| Open Source | ✅ | ✅ |
|
||||
| Free to Use | ✅ | ✅ |
|
||||
| Claude 3.5 Support | ✅ | ✅ |
|
||||
| Claude 4 Models | ❌ | ✅ |
|
||||
| SmartAI Conversational Coding | ❌ | ✅ |
|
||||
| Save All System | ❌ | ✅ |
|
||||
| Import from Folder | ❌ | ✅ |
|
||||
| Multi-User Auth | ❌ | ✅ |
|
||||
| Quick Deploy to Netlify | ❌ | ✅ |
|
||||
| Auto-detect Ollama | ❌ | ✅ |
|
||||
| Hosted Instances | ❌ | Coming Soon |
|
||||
## Quick Installation
|
||||
|
||||
## 📦 Installation
|
||||
[](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go to the latest release version!
|
||||
|
||||
Bolt.gives maintains full compatibility with bolt.diy's installation process while adding additional features:
|
||||
- Download the binary for your platform (available for Windows, macOS, and Linux)
|
||||
- **Note**: For macOS, if you get the error "This app is damaged", run:
|
||||
```bash
|
||||
xattr -cr /path/to/Bolt.app
|
||||
```
|
||||
|
||||
### Quick Installation
|
||||
## Manual installation
|
||||
|
||||
|
||||
### Option 1: 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
|
||||
```
|
||||
3. Look for `/usr/local/bin` in the output
|
||||
|
||||
## Running the Application
|
||||
|
||||
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
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
2. **Install Project Dependencies**:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Start the Application**:
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Option 2: Using Docker
|
||||
|
||||
This option requires some familiarity with Docker but provides a more isolated environment.
|
||||
|
||||
#### Additional Prerequisite
|
||||
|
||||
- Install Docker: [Download Docker](https://www.docker.com/)
|
||||
|
||||
#### Steps:
|
||||
|
||||
1. **Build the Docker Image**:
|
||||
|
||||
```bash
|
||||
# Using npm script:
|
||||
npm run dockerbuild
|
||||
|
||||
# OR using direct Docker command:
|
||||
docker build . --target bolt-ai-development
|
||||
```
|
||||
|
||||
2. **Run the Container**:
|
||||
```bash
|
||||
docker compose --profile development up
|
||||
```
|
||||
|
||||
### Option 3: Desktop Application (Electron)
|
||||
|
||||
For users who prefer a native desktop experience, bolt.diy is also available as an Electron desktop application:
|
||||
|
||||
1. **Download the Desktop App**:
|
||||
- Visit the [latest release](https://github.com/stackblitz-labs/bolt.diy/releases/latest)
|
||||
- Download the appropriate binary for your operating system
|
||||
- For macOS: Extract and run the `.dmg` file
|
||||
- For Windows: Run the `.exe` installer
|
||||
- For Linux: Extract and run the AppImage or install the `.deb` package
|
||||
|
||||
2. **Alternative**: Build from Source:
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the Electron app
|
||||
pnpm electron:build:dist # For all platforms
|
||||
# OR platform-specific:
|
||||
pnpm electron:build:mac # macOS
|
||||
pnpm electron:build:win # Windows
|
||||
pnpm electron:build:linux # Linux
|
||||
```
|
||||
|
||||
The desktop app provides the same full functionality as the web version with additional native features.
|
||||
|
||||
## Configuring API Keys and Providers
|
||||
|
||||
Bolt.diy features a modern, intuitive settings interface for managing AI providers and API keys. The settings are organized into dedicated panels for easy navigation and configuration.
|
||||
|
||||
### Accessing Provider Settings
|
||||
|
||||
1. **Open Settings**: Click the settings icon (⚙️) in the sidebar to access the settings panel
|
||||
2. **Navigate to Providers**: Select the "Providers" tab from the settings menu
|
||||
3. **Choose Provider Type**: Switch between "Cloud Providers" and "Local Providers" tabs
|
||||
|
||||
### Cloud Providers Configuration
|
||||
|
||||
The Cloud Providers tab displays all cloud-based AI services in an organized card layout:
|
||||
|
||||
#### Adding API Keys
|
||||
1. **Select Provider**: Browse the grid of available cloud providers (OpenAI, Anthropic, Google, etc.)
|
||||
2. **Toggle Provider**: Use the switch to enable/disable each provider
|
||||
3. **Set API Key**:
|
||||
- Click the provider card to expand its configuration
|
||||
- Click on the "API Key" field to enter edit mode
|
||||
- Paste your API key and press Enter to save
|
||||
- The interface shows real-time validation with green checkmarks for valid keys
|
||||
|
||||
#### Advanced Features
|
||||
- **Bulk Toggle**: Use "Enable All Cloud" to toggle all cloud providers at once
|
||||
- **Visual Status**: Green checkmarks indicate properly configured providers
|
||||
- **Provider Icons**: Each provider has a distinctive icon for easy identification
|
||||
- **Descriptions**: Helpful descriptions explain each provider's capabilities
|
||||
|
||||
### Local Providers Configuration
|
||||
|
||||
The Local Providers tab manages local AI installations and custom endpoints:
|
||||
|
||||
#### Ollama Configuration
|
||||
1. **Enable Ollama**: Toggle the Ollama provider switch
|
||||
2. **Configure Endpoint**: Set the API endpoint (defaults to `http://127.0.0.1:11434`)
|
||||
3. **Model Management**:
|
||||
- View all installed models with size and parameter information
|
||||
- Update models to latest versions with one click
|
||||
- Delete unused models
|
||||
- Install new models by entering model names
|
||||
|
||||
#### Other Local Providers
|
||||
- **LM Studio**: Configure custom base URLs for LM Studio endpoints
|
||||
- **OpenAI-like**: Connect to any OpenAI-compatible API endpoint
|
||||
- **Auto-detection**: The system automatically detects environment variables for base URLs
|
||||
|
||||
### Environment Variables vs UI Configuration
|
||||
|
||||
Bolt.diy supports both methods for maximum flexibility:
|
||||
|
||||
#### Environment Variables (Recommended for Production)
|
||||
Set API keys and base URLs in your `.env.local` file:
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/embire2/bolt.gives.git
|
||||
cd bolt.gives
|
||||
# API Keys
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Set up environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Start the development server
|
||||
pnpm run dev
|
||||
# Custom Base URLs
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
LMSTUDIO_BASE_URL=http://127.0.0.1:1234
|
||||
```
|
||||
|
||||
### Docker Installation
|
||||
#### UI-Based Configuration
|
||||
- **Real-time Updates**: Changes take effect immediately
|
||||
- **Secure Storage**: API keys are stored securely in browser cookies
|
||||
- **Visual Feedback**: Clear indicators show configuration status
|
||||
- **Easy Management**: Edit, view, and manage keys through the interface
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
### Provider-Specific Features
|
||||
|
||||
### Environment Variables
|
||||
#### OpenRouter
|
||||
- **Free Models Filter**: Toggle to show only free models when browsing
|
||||
- **Pricing Information**: View input/output costs for each model
|
||||
- **Model Search**: Fuzzy search through all available models
|
||||
|
||||
Create a `.env` file with your API keys:
|
||||
#### Ollama
|
||||
- **Model Installer**: Built-in interface to install new models
|
||||
- **Progress Tracking**: Real-time download progress for model updates
|
||||
- **Model Details**: View model size, parameters, and quantization levels
|
||||
- **Auto-refresh**: Automatically detects newly installed models
|
||||
|
||||
```env
|
||||
# Core AI Providers
|
||||
ANTHROPIC_API_KEY=your_anthropic_key
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=your_google_key
|
||||
#### Search & Navigation
|
||||
- **Fuzzy Search**: Type-ahead search across all providers and models
|
||||
- **Keyboard Navigation**: Use arrow keys and Enter to navigate quickly
|
||||
- **Clear Search**: Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to clear search
|
||||
|
||||
# Optional Providers
|
||||
GROQ_API_KEY=your_groq_key
|
||||
OLLAMA_API_BASE_URL=http://localhost:11434
|
||||
OPENROUTER_API_KEY=your_openrouter_key
|
||||
### Troubleshooting
|
||||
|
||||
# Deployment (Bolt.gives Exclusive)
|
||||
NETLIFY_AUTH_TOKEN=your_netlify_token
|
||||
GITHUB_TOKEN=your_github_token
|
||||
```
|
||||
#### Common Issues
|
||||
- **API Key Not Recognized**: Ensure you're using the correct API key format for each provider
|
||||
- **Base URL Issues**: Verify the endpoint URL is correct and accessible
|
||||
- **Model Not Loading**: Check that the provider is enabled and properly configured
|
||||
- **Environment Variables Not Working**: Restart the application after adding new environment variables
|
||||
|
||||
## 🛠️ Development
|
||||
#### Status Indicators
|
||||
- 🟢 **Green Checkmark**: Provider properly configured and ready to use
|
||||
- 🔴 **Red X**: Configuration missing or invalid
|
||||
- 🟡 **Yellow Indicator**: Provider enabled but may need additional setup
|
||||
- 🔵 **Blue Pencil**: Click to edit configuration
|
||||
|
||||
### Available Scripts
|
||||
### Supported Providers Overview
|
||||
|
||||
- `pnpm run dev` - Start development server
|
||||
- `pnpm run build` - Build for production
|
||||
- `pnpm run start` - Start production server
|
||||
- `pnpm run lint` - Run linting
|
||||
- `pnpm run typecheck` - Check TypeScript types
|
||||
#### Cloud Providers
|
||||
- **OpenAI** - GPT-4, GPT-3.5, and other OpenAI models
|
||||
- **Anthropic** - Claude 3.5 Sonnet, Claude 3 Opus, and other Claude models
|
||||
- **Google (Gemini)** - Gemini 1.5 Pro, Gemini 1.5 Flash, and other Gemini models
|
||||
- **Groq** - Fast inference with Llama, Mixtral, and other models
|
||||
- **xAI** - Grok models including Grok-2 and Grok-2 Vision
|
||||
- **DeepSeek** - DeepSeek Coder and other DeepSeek models
|
||||
- **Mistral** - Mixtral, Mistral 7B, and other Mistral models
|
||||
- **Cohere** - Command R, Command R+, and other Cohere models
|
||||
- **Together AI** - Various open-source models
|
||||
- **Perplexity** - Sonar models for search and reasoning
|
||||
- **HuggingFace** - Access to HuggingFace model hub
|
||||
- **OpenRouter** - Unified API for multiple model providers
|
||||
- **Moonshot (Kimi)** - Kimi AI models
|
||||
- **Hyperbolic** - High-performance model inference
|
||||
- **GitHub Models** - Models available through GitHub
|
||||
- **Amazon Bedrock** - AWS managed AI models
|
||||
|
||||
## 🤝 Contributing
|
||||
#### Local Providers
|
||||
- **Ollama** - Run open-source models locally with advanced model management
|
||||
- **LM Studio** - Local model inference with LM Studio
|
||||
- **OpenAI-like** - Connect to any OpenAI-compatible API endpoint
|
||||
|
||||
We welcome contributions! Our exclusive features are what make Bolt.gives special:
|
||||
> **💡 Pro Tip**: Start with OpenAI or Anthropic for the best results, then explore other providers based on your specific needs and budget considerations.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
## Setup Using Git (For Developers only)
|
||||
|
||||
## 📜 License
|
||||
This method is recommended for developers who want to:
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
- Contribute to the project
|
||||
- Stay updated with the latest changes
|
||||
- Switch between different versions
|
||||
- Create custom modifications
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
#### Prerequisites
|
||||
|
||||
- Original [bolt.diy](https://github.com/stackblitz-labs/bolt.diy) team for the foundation
|
||||
- All contributors who have submitted PRs and features
|
||||
- The open-source community for continuous support and feedback
|
||||
1. Install Git: [Download Git](https://git-scm.com/downloads)
|
||||
|
||||
## 📞 Support
|
||||
#### Initial Setup
|
||||
|
||||
- **GitHub Issues**: [Report bugs or request features](https://github.com/embire2/bolt.gives/issues)
|
||||
- **Community**: Join the discussion in our GitHub Discussions
|
||||
- **Email**: support@bolt.gives (coming soon)
|
||||
1. **Clone the Repository**:
|
||||
|
||||
```bash
|
||||
git clone -b stable https://github.com/stackblitz-labs/bolt.diy.git
|
||||
```
|
||||
|
||||
2. **Navigate to Project Directory**:
|
||||
|
||||
```bash
|
||||
cd bolt.diy
|
||||
```
|
||||
|
||||
3. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Start the Development Server**:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch:
|
||||
```bash
|
||||
git checkout main
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
|
||||
|
||||
>**Open the WebUI to test (Default: http://localhost:5173)**
|
||||
> - Beginners:
|
||||
> - Try to use a sophisticated Provider/Model like Anthropic with Claude Sonnet 3.x Models to get best results
|
||||
> - Explanation: The System Prompt currently implemented in bolt.diy cant cover the best performance for all providers and models out there. So it works better with some models, then other, even if the models itself are perfect for >programming
|
||||
> - Future: Planned is a Plugin/Extentions-Library so there can be different System Prompts for different Models, which will help to get better results
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
3. **Update Dependencies**:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Restore Your Local Changes** (if any):
|
||||
```bash
|
||||
git stash pop
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Built with ❤️ by the Bolt.gives Community</strong>
|
||||
<br>
|
||||
<em>Taking AI Development to the Next Level</em>
|
||||
</div>
|
||||
## Available Scripts
|
||||
|
||||
- **`pnpm run dev`**: Starts the development server.
|
||||
- **`pnpm run build`**: Builds the project.
|
||||
- **`pnpm run start`**: Runs the built application locally using Wrangler Pages.
|
||||
- **`pnpm run preview`**: Builds and runs the production build locally.
|
||||
- **`pnpm test`**: Runs the test suite using Vitest.
|
||||
- **`pnpm run typecheck`**: Runs TypeScript type checking.
|
||||
- **`pnpm run typegen`**: Generates TypeScript types using Wrangler.
|
||||
- **`pnpm run deploy`**: Deploys the project to Cloudflare Pages.
|
||||
- **`pnpm run lint`**: Runs ESLint to check for code issues.
|
||||
- **`pnpm run lint:fix`**: Automatically fixes linting issues.
|
||||
- **`pnpm run clean`**: Cleans build artifacts and cache.
|
||||
- **`pnpm run prepare`**: Sets up husky for git hooks.
|
||||
- **Docker Scripts**:
|
||||
- **`pnpm run dockerbuild`**: Builds the Docker image for development.
|
||||
- **`pnpm run dockerbuild:prod`**: Builds the Docker image for production.
|
||||
- **`pnpm run dockerrun`**: Runs the Docker container.
|
||||
- **`pnpm run dockerstart`**: Starts the Docker container with proper bindings.
|
||||
- **Electron Scripts**:
|
||||
- **`pnpm electron:build:deps`**: Builds Electron main and preload scripts.
|
||||
- **`pnpm electron:build:main`**: Builds the Electron main process.
|
||||
- **`pnpm electron:build:preload`**: Builds the Electron preload script.
|
||||
- **`pnpm electron:build:renderer`**: Builds the Electron renderer.
|
||||
- **`pnpm electron:build:unpack`**: Creates an unpacked Electron build.
|
||||
- **`pnpm electron:build:mac`**: Builds for macOS.
|
||||
- **`pnpm electron:build:win`**: Builds for Windows.
|
||||
- **`pnpm electron:build:linux`**: Builds for Linux.
|
||||
- **`pnpm electron:build:dist`**: Builds for all platforms.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Check out our [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo).
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).
|
||||
|
||||
|
||||
# Licensing
|
||||
**Who needs a commercial WebContainer API license?**
|
||||
|
||||
bolt.diy source code is distributed as MIT, but it uses WebContainers API that [requires licensing](https://webcontainers.io/enterprise) for production usage in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
|
||||
# Test commit to trigger Security Analysis workflow
|
||||
|
||||
@@ -3,8 +3,7 @@ import { toast } from 'react-toastify';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import type { NetlifySite, NetlifyDeploy, NetlifyBuild } from '~/types/netlify';
|
||||
import { NetlifyQuickConnect } from './NetlifyQuickConnect';
|
||||
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
|
||||
import {
|
||||
CloudIcon,
|
||||
BuildingLibraryIcon,
|
||||
@@ -43,16 +42,29 @@ interface SiteAction {
|
||||
}
|
||||
|
||||
export default function NetlifyConnection() {
|
||||
console.log('NetlifyConnection component mounted');
|
||||
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [fetchingStats, setFetchingStats] = useState(false);
|
||||
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||
|
||||
console.log('NetlifyConnection initial state:', {
|
||||
connection: {
|
||||
user: connection.user,
|
||||
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
|
||||
},
|
||||
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
|
||||
});
|
||||
|
||||
const [deploymentCount, setDeploymentCount] = useState(0);
|
||||
const [lastUpdated, setLastUpdated] = useState('');
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Add site actions
|
||||
const siteActions: SiteAction[] = [
|
||||
@@ -139,6 +151,8 @@ export default function NetlifyConnection() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Netlify: Running initialization useEffect');
|
||||
|
||||
// Initialize connection with environment token if available
|
||||
initializeNetlifyConnection();
|
||||
}, []);
|
||||
@@ -159,6 +173,46 @@ export default function NetlifyConnection() {
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!tokenInput) {
|
||||
toast.error('Please enter a Netlify API token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenInput}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const userData = (await response.json()) as NetlifyUser;
|
||||
|
||||
// Update the connection store
|
||||
updateNetlifyConnection({
|
||||
user: userData,
|
||||
token: tokenInput,
|
||||
});
|
||||
|
||||
toast.success('Connected to Netlify successfully');
|
||||
|
||||
// Fetch stats after successful connection
|
||||
fetchNetlifyStats(tokenInput);
|
||||
} catch (error) {
|
||||
console.error('Error connecting to Netlify:', error);
|
||||
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
setTokenInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('netlify_connection');
|
||||
@@ -608,15 +662,76 @@ export default function NetlifyConnection() {
|
||||
|
||||
{!connection.user ? (
|
||||
<div className="mt-4">
|
||||
<NetlifyQuickConnect
|
||||
onSuccess={() => {
|
||||
// Fetch stats after successful connection
|
||||
if (connection.token) {
|
||||
fetchNetlifyStats(connection.token);
|
||||
}
|
||||
}}
|
||||
showInstructions={true}
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
API Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
placeholder="Enter your Netlify API 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-bolt-elements-borderColorActive',
|
||||
'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-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
{/* Debug info - remove this later */}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
|
||||
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
|
||||
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Debug button - remove this later */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
console.log('Manual Netlify auto-connect test');
|
||||
await initializeNetlifyConnection();
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
Test Auto-Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full gap-4 mt-4">
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { updateNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface NetlifyQuickConnectProps {
|
||||
onSuccess?: () => void;
|
||||
showInstructions?: boolean;
|
||||
}
|
||||
|
||||
export const NetlifyQuickConnect: React.FC<NetlifyQuickConnectProps> = ({ onSuccess, showInstructions = true }) => {
|
||||
const [token, setToken] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your Netlify API token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Validate token with Netlify API
|
||||
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or authentication failed');
|
||||
}
|
||||
|
||||
const userData = (await response.json()) as any;
|
||||
|
||||
// Fetch initial site statistics
|
||||
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
let sites: any[] = [];
|
||||
|
||||
if (sitesResponse.ok) {
|
||||
sites = (await sitesResponse.json()) as any[];
|
||||
}
|
||||
|
||||
// Update the connection store
|
||||
updateNetlifyConnection({
|
||||
user: userData,
|
||||
token,
|
||||
stats: {
|
||||
sites,
|
||||
totalSites: sites.length,
|
||||
deploys: [],
|
||||
builds: [],
|
||||
lastDeployTime: '',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
|
||||
setToken(''); // Clear the token field
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Netlify connection error:', error);
|
||||
toast.error('Failed to connect to Netlify. Please check your token.');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary">Personal Access Token</label>
|
||||
{showInstructions && (
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="text-xs text-accent-500 hover:text-accent-600 flex items-center gap-1"
|
||||
>
|
||||
<span className={classNames('i-ph:question-circle', showHelp ? 'text-accent-600' : '')} />
|
||||
How to get token
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && token.trim() && !isConnecting) {
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your Netlify API token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 pr-10 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
{token && (
|
||||
<button
|
||||
onClick={() => setToken('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
|
||||
>
|
||||
<span className="i-ph:x text-lg" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHelp && showInstructions && (
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3 animate-in fade-in-0 slide-in-from-top-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="i-ph:info text-accent-500 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
Getting your Netlify Personal Access Token:
|
||||
</p>
|
||||
<ol className="space-y-2 text-bolt-elements-textSecondary">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent-500 font-medium">1.</span>
|
||||
<span>
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-500 hover:text-accent-600 underline inline-flex items-center gap-1"
|
||||
>
|
||||
Netlify Account Settings
|
||||
<span className="i-ph:arrow-square-out text-xs" />
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent-500 font-medium">2.</span>
|
||||
<span>Navigate to "Applications" → "Personal access tokens"</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent-500 font-medium">3.</span>
|
||||
<span>Click "New access token"</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent-500 font-medium">4.</span>
|
||||
<span>Give it a descriptive name (e.g., "bolt.diy deployment")</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent-500 font-medium">5.</span>
|
||||
<span>Copy the token and paste it above</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div className="pt-2 border-t border-bolt-elements-borderColor">
|
||||
<p className="text-xs text-bolt-elements-textTertiary">
|
||||
<strong>Note:</strong> Keep your token safe! It provides full access to your Netlify account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2 transition-all text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<span className="i-ph:arrow-square-out" />
|
||||
Get Token
|
||||
</a>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg font-medium transition-all text-sm',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:plug-charging" />
|
||||
Connect to Netlify
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-accent-500/10 border border-accent-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="i-ph:lightning text-accent-500 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">Quick Tip</p>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
Once connected, you can deploy any project with a single click directly from the editor!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
|
||||
useEffect(() => {
|
||||
// If not loading and not authenticated, redirect to auth page
|
||||
if (!authState.loading && !authState.isAuthenticated) {
|
||||
navigate('/auth');
|
||||
}
|
||||
}, [authState.loading, authState.isAuthenticated, navigate]);
|
||||
|
||||
// Show loading state
|
||||
if (authState.loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bolt-elements-background-depth-1">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
|
||||
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
|
||||
</div>
|
||||
<p className="text-bolt-elements-textSecondary">Loading your workspace...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If not authenticated, don't render children (will redirect)
|
||||
if (!authState.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// HOC for protecting pages
|
||||
export function withAuth<P extends object>(wrappedComponent: React.ComponentType<P>) {
|
||||
const Component = wrappedComponent;
|
||||
|
||||
return function ProtectedComponent(props: P) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Component {...props} />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -176,18 +176,9 @@ export const AssistantMessage = memo(
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="prose prose-invert max-w-none text-bolt-elements-textPrimary">
|
||||
<Markdown
|
||||
append={append}
|
||||
chatMode={chatMode}
|
||||
setChatMode={setChatMode}
|
||||
model={model}
|
||||
provider={provider}
|
||||
html
|
||||
>
|
||||
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
{toolInvocations && toolInvocations.length > 0 && (
|
||||
<ToolInvocations
|
||||
toolInvocations={toolInvocations}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { motion } from 'framer-motion';
|
||||
import { UserMenu } from '~/components/header/UserMenu';
|
||||
|
||||
/**
|
||||
* Authenticated chat component that ensures user is logged in
|
||||
*/
|
||||
export function AuthenticatedChat() {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status after component mounts
|
||||
const checkAuth = async () => {
|
||||
// Give auth store time to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const state = authStore.get();
|
||||
|
||||
if (!state.loading) {
|
||||
if (!state.isAuthenticated) {
|
||||
navigate('/auth');
|
||||
} else {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to auth changes
|
||||
const unsubscribe = authStore.subscribe((state) => {
|
||||
if (!state.loading && !state.isAuthenticated) {
|
||||
navigate('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Show loading state
|
||||
if (authState.loading || !isInitialized) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
|
||||
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
|
||||
</div>
|
||||
<p className="text-bolt-elements-textSecondary">Initializing workspace...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If not authenticated, don't render (will redirect)
|
||||
if (!authState.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render authenticated content with enhanced header
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header>
|
||||
<UserMenu />
|
||||
</Header>
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,13 +73,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} parts={parts} />
|
||||
) : (
|
||||
<>
|
||||
{props.model?.includes('smartai') && index === messages.length - 1 && isStreaming && (
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-blue-400">
|
||||
<span className="i-ph:sparkle animate-pulse" />
|
||||
<span className="font-medium">SmartAI is explaining the process...</span>
|
||||
</div>
|
||||
)}
|
||||
<AssistantMessage
|
||||
content={content}
|
||||
annotations={message.annotations}
|
||||
@@ -94,7 +87,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
parts={parts}
|
||||
addToolResult={props.addToolResult}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,84 @@
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Fuzzy search utilities
|
||||
const levenshteinDistance = (str1: string, str2: string): number => {
|
||||
const matrix = [];
|
||||
|
||||
for (let i = 0; i <= str2.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
|
||||
for (let j = 0; j <= str1.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= str2.length; i++) {
|
||||
for (let j = 1; j <= str1.length; j++) {
|
||||
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
};
|
||||
|
||||
const fuzzyMatch = (query: string, text: string): { score: number; matches: boolean } => {
|
||||
if (!query) {
|
||||
return { score: 0, matches: true };
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return { score: 0, matches: false };
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
// Exact substring match gets highest score
|
||||
if (textLower.includes(queryLower)) {
|
||||
return { score: 100 - (textLower.indexOf(queryLower) / textLower.length) * 20, matches: true };
|
||||
}
|
||||
|
||||
// Fuzzy match with reasonable threshold
|
||||
const distance = levenshteinDistance(queryLower, textLower);
|
||||
const maxLen = Math.max(queryLower.length, textLower.length);
|
||||
const similarity = 1 - distance / maxLen;
|
||||
|
||||
return {
|
||||
score: similarity > 0.6 ? similarity * 80 : 0,
|
||||
matches: similarity > 0.6,
|
||||
};
|
||||
};
|
||||
|
||||
const highlightText = (text: string, query: string): string => {
|
||||
if (!query) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
|
||||
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 text-current">$1</mark>');
|
||||
};
|
||||
|
||||
const formatContextSize = (tokens: number): string => {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(0)}K`;
|
||||
}
|
||||
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
@@ -40,12 +115,14 @@ export const ModelSelector = ({
|
||||
modelLoading,
|
||||
}: ModelSelectorProps) => {
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
||||
const [debouncedModelSearchQuery, setDebouncedModelSearchQuery] = useState('');
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
|
||||
const modelSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [providerSearchQuery, setProviderSearchQuery] = useState('');
|
||||
const [debouncedProviderSearchQuery, setDebouncedProviderSearchQuery] = useState('');
|
||||
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
|
||||
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
|
||||
const providerSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -53,6 +130,23 @@ export const ModelSelector = ({
|
||||
const providerDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);
|
||||
|
||||
// Debounce search queries
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedModelSearchQuery(modelSearchQuery);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [modelSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedProviderSearchQuery(providerSearchQuery);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [providerSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -71,24 +165,64 @@ export const ModelSelector = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredModels = [...modelList]
|
||||
.filter((e) => e.provider === provider?.name && e.name)
|
||||
const filteredModels = useMemo(() => {
|
||||
const baseModels = [...modelList].filter((e) => e.provider === provider?.name && e.name);
|
||||
|
||||
return baseModels
|
||||
.filter((model) => {
|
||||
// Apply free models filter
|
||||
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
return (
|
||||
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
|
||||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase())
|
||||
);
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.map((model) => {
|
||||
// Calculate search scores for fuzzy matching
|
||||
const labelMatch = fuzzyMatch(debouncedModelSearchQuery, model.label);
|
||||
const nameMatch = fuzzyMatch(debouncedModelSearchQuery, model.name);
|
||||
const contextMatch = fuzzyMatch(debouncedModelSearchQuery, formatContextSize(model.maxTokenAllowed));
|
||||
|
||||
const filteredProviders = providerList.filter((p) =>
|
||||
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
|
||||
);
|
||||
const bestScore = Math.max(labelMatch.score, nameMatch.score, contextMatch.score);
|
||||
const matches = labelMatch.matches || nameMatch.matches || contextMatch.matches || !debouncedModelSearchQuery; // Show all if no query
|
||||
|
||||
return {
|
||||
...model,
|
||||
searchScore: bestScore,
|
||||
searchMatches: matches,
|
||||
highlightedLabel: highlightText(model.label, debouncedModelSearchQuery),
|
||||
highlightedName: highlightText(model.name, debouncedModelSearchQuery),
|
||||
};
|
||||
})
|
||||
.filter((model) => model.searchMatches)
|
||||
.sort((a, b) => {
|
||||
// Sort by search score (highest first), then by label
|
||||
if (debouncedModelSearchQuery) {
|
||||
return b.searchScore - a.searchScore;
|
||||
}
|
||||
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [modelList, provider?.name, showFreeModelsOnly, debouncedModelSearchQuery]);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
if (!debouncedProviderSearchQuery) {
|
||||
return providerList;
|
||||
}
|
||||
|
||||
return providerList
|
||||
.map((provider) => {
|
||||
const match = fuzzyMatch(debouncedProviderSearchQuery, provider.name);
|
||||
return {
|
||||
...provider,
|
||||
searchScore: match.score,
|
||||
searchMatches: match.matches,
|
||||
highlightedName: highlightText(provider.name, debouncedProviderSearchQuery),
|
||||
};
|
||||
})
|
||||
.filter((provider) => provider.searchMatches)
|
||||
.sort((a, b) => b.searchScore - a.searchScore);
|
||||
}, [providerList, debouncedProviderSearchQuery]);
|
||||
|
||||
// Reset free models filter when provider changes
|
||||
useEffect(() => {
|
||||
@@ -97,11 +231,30 @@ export const ModelSelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedModelIndex(-1);
|
||||
}, [modelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
|
||||
}, [debouncedModelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedProviderIndex(-1);
|
||||
}, [providerSearchQuery, isProviderDropdownOpen]);
|
||||
}, [debouncedProviderSearchQuery, isProviderDropdownOpen]);
|
||||
|
||||
// Clear search functions
|
||||
const clearModelSearch = useCallback(() => {
|
||||
setModelSearchQuery('');
|
||||
setDebouncedModelSearchQuery('');
|
||||
|
||||
if (modelSearchInputRef.current) {
|
||||
modelSearchInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearProviderSearch = useCallback(() => {
|
||||
setProviderSearchQuery('');
|
||||
setDebouncedProviderSearchQuery('');
|
||||
|
||||
if (providerSearchInputRef.current) {
|
||||
providerSearchInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModelDropdownOpen && modelSearchInputRef.current) {
|
||||
@@ -137,6 +290,7 @@ export const ModelSelector = ({
|
||||
setModel?.(selectedModel.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
setDebouncedModelSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -144,12 +298,20 @@ export const ModelSelector = ({
|
||||
e.preventDefault();
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
setDebouncedModelSearchQuery('');
|
||||
break;
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'k':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
clearModelSearch();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -186,6 +348,7 @@ export const ModelSelector = ({
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
setDebouncedProviderSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -193,12 +356,20 @@ export const ModelSelector = ({
|
||||
e.preventDefault();
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
setDebouncedProviderSearchQuery('');
|
||||
break;
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
|
||||
setIsProviderDropdownOpen(false);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'k':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
clearProviderSearch();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -292,9 +463,9 @@ export const ModelSelector = ({
|
||||
type="text"
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
placeholder="Search providers..."
|
||||
placeholder="Search providers... (⌘K to clear)"
|
||||
className={classNames(
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'w-full pl-8 pr-8 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',
|
||||
@@ -307,6 +478,19 @@ export const ModelSelector = ({
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
{providerSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProviderSearch();
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,7 +511,18 @@ export const ModelSelector = ({
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
|
||||
<div className="px-3 py-3 text-sm">
|
||||
<div className="text-bolt-elements-textTertiary mb-1">
|
||||
{debouncedProviderSearchQuery
|
||||
? `No providers match "${debouncedProviderSearchQuery}"`
|
||||
: 'No providers found'}
|
||||
</div>
|
||||
{debouncedProviderSearchQuery && (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Try searching for provider names like "OpenAI", "Anthropic", or "Google"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredProviders.map((providerOption, index) => (
|
||||
<div
|
||||
@@ -360,10 +555,15 @@ export const ModelSelector = ({
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
setDebouncedProviderSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedProviderIndex === index ? 0 : -1}
|
||||
>
|
||||
{providerOption.name}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (providerOption as any).highlightedName || providerOption.name,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -396,15 +596,7 @@ export const ModelSelector = ({
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
|
||||
{modelList.find((m) => m.name === model)?.isSmartAIEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
|
||||
<span className="i-ph:sparkle text-xs text-blue-400" />
|
||||
<span className="text-xs text-blue-400 font-medium">Active</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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',
|
||||
@@ -449,6 +641,14 @@ export const ModelSelector = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Result Count */}
|
||||
{debouncedModelSearchQuery && filteredModels.length > 0 && (
|
||||
<div className="text-xs text-bolt-elements-textTertiary px-1">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} found
|
||||
{filteredModels.length > 5 && ' (showing best matches)'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -456,9 +656,9 @@ export const ModelSelector = ({
|
||||
type="text"
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
placeholder="Search models... (⌘K to clear)"
|
||||
className={classNames(
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'w-full pl-8 pr-8 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',
|
||||
@@ -471,6 +671,19 @@ export const ModelSelector = ({
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
{modelSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearModelSearch();
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -491,16 +704,37 @@ export const ModelSelector = ({
|
||||
)}
|
||||
>
|
||||
{modelLoading === 'all' || modelLoading === provider?.name ? (
|
||||
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
|
||||
<div className="px-3 py-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textTertiary">
|
||||
<span className="i-ph:spinner animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">
|
||||
{showFreeModelsOnly ? 'No free models found' : 'No models found'}
|
||||
<div className="px-3 py-3 text-sm">
|
||||
<div className="text-bolt-elements-textTertiary mb-1">
|
||||
{debouncedModelSearchQuery
|
||||
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
|
||||
: showFreeModelsOnly
|
||||
? 'No free models available'
|
||||
: 'No models available'}
|
||||
</div>
|
||||
{debouncedModelSearchQuery && (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
|
||||
</div>
|
||||
)}
|
||||
{showFreeModelsOnly && !debouncedModelSearchQuery && (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Try disabling the "Free models only" filter to see all available models
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((modelOption, index) => (
|
||||
<div
|
||||
ref={(el) => (modelOptionsRef.current[index] = el)}
|
||||
key={index} // Consider using modelOption.name if unique
|
||||
key={modelOption.name}
|
||||
role="option"
|
||||
aria-selected={model === modelOption.name}
|
||||
className={classNames(
|
||||
@@ -518,22 +752,38 @@ export const ModelSelector = ({
|
||||
setModel?.(modelOption.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
setDebouncedModelSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedModelIndex === index ? 0 : -1}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
{modelOption.label}
|
||||
{modelOption.isSmartAIEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
|
||||
<span className="i-ph:sparkle text-xs text-blue-400" />
|
||||
<span className="text-xs text-blue-400 font-medium">SmartAI</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (modelOption as any).highlightedLabel || modelOption.label,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-bolt-elements-textTertiary">
|
||||
{formatContextSize(modelOption.maxTokenAllowed)} tokens
|
||||
</span>
|
||||
{debouncedModelSearchQuery && (modelOption as any).searchScore > 70 && (
|
||||
<span className="text-xs text-green-500 font-medium">
|
||||
{(modelOption as any).searchScore.toFixed(0)}% match
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isModelLikelyFree(modelOption, provider?.name) && (
|
||||
<span className="i-ph:gift text-xs text-purple-400 ml-2" title="Free model" />
|
||||
<span className="i-ph:gift text-xs text-purple-400" title="Free model" />
|
||||
)}
|
||||
{model === modelOption.name && (
|
||||
<span className="i-ph:check text-xs text-green-500" title="Selected" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
interface SmartAIToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
provider?: ProviderInfo;
|
||||
model?: string;
|
||||
modelList: ModelInfo[];
|
||||
}
|
||||
|
||||
export const SmartAiToggle: React.FC<SmartAIToggleProps> = ({ enabled, onToggle, provider, model, modelList }) => {
|
||||
// Check if current model supports SmartAI
|
||||
const currentModel = modelList.find((m) => m.name === model);
|
||||
const isSupported = currentModel?.supportsSmartAI && (provider?.name === 'Anthropic' || provider?.name === 'OpenAI');
|
||||
|
||||
if (!isSupported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(!enabled)}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
|
||||
'border border-bolt-elements-borderColor',
|
||||
enabled
|
||||
? 'bg-gradient-to-r from-blue-500/20 to-purple-500/20 border-blue-500/30'
|
||||
: 'bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
title="Toggle SmartAI for detailed conversational feedback"
|
||||
>
|
||||
<span
|
||||
className={classNames('i-ph:sparkle text-sm', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
|
||||
/>
|
||||
<span
|
||||
className={classNames('text-xs font-medium', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
|
||||
>
|
||||
SmartAI {enabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Create a mobile app about bolt.diy' },
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ text: 'Build a simple blog using Astro' },
|
||||
{ text: 'Create a cookie consent form using Material UI' },
|
||||
{ text: 'Make a space invaders game' },
|
||||
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
|
||||
];
|
||||
|
||||
interface WelcomeMessageProps {
|
||||
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
||||
}
|
||||
|
||||
export function WelcomeMessage({ sendMessage }: WelcomeMessageProps) {
|
||||
const authState = useStore(authStore);
|
||||
const timeOfDay = new Date().getHours();
|
||||
|
||||
const getGreeting = () => {
|
||||
if (timeOfDay < 12) {
|
||||
return 'Good morning';
|
||||
}
|
||||
|
||||
if (timeOfDay < 17) {
|
||||
return 'Good afternoon';
|
||||
}
|
||||
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-6 w-full max-w-3xl mx-auto mt-8">
|
||||
{/* Personalized Greeting */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-bolt-elements-textPrimary mb-2">
|
||||
{getGreeting()}, {authState.user?.firstName || 'Developer'}!
|
||||
</h1>
|
||||
<p className="text-lg text-bolt-elements-textSecondary">What would you like to build today?</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Example Prompts */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<p className="text-sm text-bolt-elements-textTertiary text-center">Try one of these examples to get started:</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 + index * 0.05 }}
|
||||
onClick={(event) => sendMessage?.(event, examplePrompt.text)}
|
||||
className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-all hover:scale-105"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* User Stats */}
|
||||
{authState.user && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="text-center text-xs text-bolt-elements-textTertiary"
|
||||
>
|
||||
<p>
|
||||
Logged in as{' '}
|
||||
<span className="text-bolt-elements-textSecondary font-medium">@{authState.user.username}</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { isGitLabConnected } from '~/lib/stores/gitlabConnection';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { DeployDialog } from './DeployDialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useState } from 'react';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
|
||||
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
|
||||
import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client';
|
||||
import { useGitLabDeploy } from '~/components/deploy/GitLabDeploy.client';
|
||||
import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog';
|
||||
import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
|
||||
|
||||
export const DeployButton = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
interface DeployButtonProps {
|
||||
onVercelDeploy?: () => Promise<void>;
|
||||
onNetlifyDeploy?: () => Promise<void>;
|
||||
onGitHubDeploy?: () => Promise<void>;
|
||||
onGitLabDeploy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DeployButton = ({
|
||||
onVercelDeploy,
|
||||
onNetlifyDeploy,
|
||||
onGitHubDeploy,
|
||||
onGitLabDeploy,
|
||||
}: DeployButtonProps) => {
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const gitlabIsConnected = useStore(isGitLabConnected);
|
||||
const [activePreviewIndex] = useState(0);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | null>(null);
|
||||
const isStreaming = useStore(streamingState);
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
const { handleGitHubDeploy } = useGitHubDeploy();
|
||||
const { handleGitLabDeploy } = useGitLabDeploy();
|
||||
const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false);
|
||||
const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
|
||||
const [githubDeploymentFiles, setGithubDeploymentFiles] = useState<Record<string, string> | null>(null);
|
||||
const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState<Record<string, string> | null>(null);
|
||||
const [githubProjectName, setGithubProjectName] = useState('');
|
||||
const [gitlabProjectName, setGitlabProjectName] = useState('');
|
||||
|
||||
const handleVercelDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
try {
|
||||
if (onVercelDeploy) {
|
||||
await onVercelDeploy();
|
||||
} else {
|
||||
await handleVercelDeploy();
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNetlifyDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('netlify');
|
||||
|
||||
try {
|
||||
if (onNetlifyDeploy) {
|
||||
await onNetlifyDeploy();
|
||||
} else {
|
||||
await handleNetlifyDeploy();
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('github');
|
||||
|
||||
try {
|
||||
if (onGitHubDeploy) {
|
||||
await onGitHubDeploy();
|
||||
} else {
|
||||
const result = await handleGitHubDeploy();
|
||||
|
||||
if (result && result.success && result.files) {
|
||||
setGithubDeploymentFiles(result.files);
|
||||
setGithubProjectName(result.projectName);
|
||||
setShowGitHubDeploymentDialog(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitLabDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('gitlab');
|
||||
|
||||
try {
|
||||
if (onGitLabDeploy) {
|
||||
await onGitLabDeploy();
|
||||
} else {
|
||||
const result = await handleGitLabDeploy();
|
||||
|
||||
if (result && result.success && result.files) {
|
||||
setGitlabDeploymentFiles(result.files);
|
||||
setGitlabProjectName(result.projectName);
|
||||
setShowGitLabDeploymentDialog(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={!activePreview || isStreaming}
|
||||
className="px-4 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2 text-sm font-medium"
|
||||
title="Deploy your project"
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
disabled={isDeploying || !activePreview || isStreaming}
|
||||
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
||||
>
|
||||
<span className="i-ph:rocket-launch text-lg" />
|
||||
Deploy
|
||||
</button>
|
||||
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
|
||||
<span className={classNames('i-ph:caret-down transition-transform')} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'z-[250]',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'py-1',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !netlifyConn.user}
|
||||
onClick={handleNetlifyDeployClick}
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/netlify"
|
||||
/>
|
||||
<span className="mx-auto">
|
||||
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
|
||||
</span>
|
||||
{netlifyConn.user && <NetlifyDeploymentLink />}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DeployDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} />
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !vercelConn.user}
|
||||
onClick={handleVercelDeployClick}
|
||||
>
|
||||
<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">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
|
||||
{vercelConn.user && <VercelDeploymentLink />}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview}
|
||||
onClick={handleGitHubDeployClick}
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/github"
|
||||
alt="github"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to GitHub</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabIsConnected,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !gitlabIsConnected}
|
||||
onClick={handleGitLabDeployClick}
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/gitlab"
|
||||
alt="gitlab"
|
||||
/>
|
||||
<span className="mx-auto">{!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'}</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/cloudflare"
|
||||
alt="cloudflare"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
{/* GitHub Deployment Dialog */}
|
||||
{showGitHubDeploymentDialog && githubDeploymentFiles && (
|
||||
<GitHubDeploymentDialog
|
||||
isOpen={showGitHubDeploymentDialog}
|
||||
onClose={() => setShowGitHubDeploymentDialog(false)}
|
||||
projectName={githubProjectName}
|
||||
files={githubDeploymentFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* GitLab Deployment Dialog */}
|
||||
{showGitLabDeploymentDialog && gitlabDeploymentFiles && (
|
||||
<GitLabDeploymentDialog
|
||||
isOpen={showGitLabDeploymentDialog}
|
||||
onClose={() => setShowGitLabDeploymentDialog(false)}
|
||||
projectName={gitlabProjectName}
|
||||
files={gitlabDeploymentFiles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { useNetlifyDeploy } from './NetlifyDeploy.client';
|
||||
import { useVercelDeploy } from './VercelDeploy.client';
|
||||
import { useGitHubDeploy } from './GitHubDeploy.client';
|
||||
import { GitHubDeploymentDialog } from './GitHubDeploymentDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface DeployDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DeployProvider {
|
||||
id: 'netlify' | 'vercel' | 'github' | 'cloudflare';
|
||||
name: string;
|
||||
iconClass: string;
|
||||
iconColor?: string;
|
||||
connected: boolean;
|
||||
comingSoon?: boolean;
|
||||
description: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
const NetlifyConnectForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
const [token, setToken] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your Netlify API token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Validate token with Netlify API
|
||||
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or authentication failed');
|
||||
}
|
||||
|
||||
const userData = (await response.json()) as any;
|
||||
|
||||
// Update the connection store
|
||||
updateNetlifyConnection({
|
||||
user: userData,
|
||||
token,
|
||||
});
|
||||
|
||||
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Netlify connection error:', error);
|
||||
toast.error('Failed to connect to Netlify. Please check your token.');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">Connect to Netlify</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
To deploy your project to Netlify, you need to connect your account using a Personal Access Token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-1">Personal Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Enter your Netlify API token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-accent-500 hover:text-accent-600 inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token from Netlify
|
||||
<span className="i-ph:arrow-square-out text-xs" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-bolt-elements-textSecondary font-medium">How to get your token:</p>
|
||||
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
|
||||
<li>Go to your Netlify account settings</li>
|
||||
<li>Navigate to "Applications" → "Personal access tokens"</li>
|
||||
<li>Click "New access token"</li>
|
||||
<li>Give it a descriptive name (e.g., "bolt.diy deployment")</li>
|
||||
<li>Copy the token and paste it here</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:plug-charging" />
|
||||
Connect Account
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeployDialog: React.FC<DeployDialogProps> = ({ isOpen, onClose }) => {
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const [selectedProvider, setSelectedProvider] = useState<'netlify' | 'vercel' | 'github' | null>(null);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [showGitHubDialog, setShowGitHubDialog] = useState(false);
|
||||
const [githubFiles, setGithubFiles] = useState<Record<string, string> | null>(null);
|
||||
const [githubProjectName, setGithubProjectName] = useState('');
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleGitHubDeploy } = useGitHubDeploy();
|
||||
|
||||
const providers: DeployProvider[] = [
|
||||
{
|
||||
id: 'netlify',
|
||||
name: 'Netlify',
|
||||
iconClass: 'i-simple-icons:netlify',
|
||||
iconColor: 'text-[#00C7B7]',
|
||||
connected: !!netlifyConn.user,
|
||||
description: 'Deploy your site with automatic SSL, global CDN, and continuous deployment',
|
||||
features: [
|
||||
'Automatic SSL certificates',
|
||||
'Global CDN',
|
||||
'Instant rollbacks',
|
||||
'Deploy previews',
|
||||
'Form handling',
|
||||
'Serverless functions',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vercel',
|
||||
name: 'Vercel',
|
||||
iconClass: 'i-simple-icons:vercel',
|
||||
connected: !!vercelConn.user,
|
||||
description: 'Deploy with the platform built for frontend developers',
|
||||
features: [
|
||||
'Zero-config deployments',
|
||||
'Edge Functions',
|
||||
'Analytics',
|
||||
'Web Vitals monitoring',
|
||||
'Preview deployments',
|
||||
'Automatic HTTPS',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
iconClass: 'i-simple-icons:github',
|
||||
connected: true, // GitHub doesn't require separate auth
|
||||
description: 'Deploy to GitHub Pages or create a repository',
|
||||
features: [
|
||||
'Free hosting with GitHub Pages',
|
||||
'Version control integration',
|
||||
'Collaborative development',
|
||||
'Actions & Workflows',
|
||||
'Issue tracking',
|
||||
'Pull requests',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cloudflare',
|
||||
name: 'Cloudflare Pages',
|
||||
iconClass: 'i-simple-icons:cloudflare',
|
||||
iconColor: 'text-[#F38020]',
|
||||
connected: false,
|
||||
comingSoon: true,
|
||||
description: "Deploy on Cloudflare's global network",
|
||||
features: [
|
||||
'Unlimited bandwidth',
|
||||
'DDoS protection',
|
||||
'Web Analytics',
|
||||
'Edge Workers',
|
||||
'Custom domains',
|
||||
'Automatic builds',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeploy = async (provider: 'netlify' | 'vercel' | 'github') => {
|
||||
setIsDeploying(true);
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
|
||||
if (provider === 'netlify') {
|
||||
success = await handleNetlifyDeploy();
|
||||
} else if (provider === 'vercel') {
|
||||
success = await handleVercelDeploy();
|
||||
} else if (provider === 'github') {
|
||||
const result = await handleGitHubDeploy();
|
||||
|
||||
if (result && typeof result === 'object' && result.success && result.files) {
|
||||
setGithubFiles(result.files);
|
||||
setGithubProjectName(result.projectName);
|
||||
setShowGitHubDialog(true);
|
||||
onClose();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
success = result && typeof result === 'object' ? result.success : false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(
|
||||
`Successfully deployed to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Deployment error:', error);
|
||||
toast.error(
|
||||
`Failed to deploy to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
|
||||
);
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderProviderContent = () => {
|
||||
if (!selectedProvider) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() =>
|
||||
!provider.comingSoon && setSelectedProvider(provider.id as 'netlify' | 'vercel' | 'github')
|
||||
}
|
||||
disabled={provider.comingSoon}
|
||||
className={classNames(
|
||||
'p-4 rounded-lg border-2 transition-all text-left',
|
||||
'hover:border-accent-500 hover:bg-bolt-elements-background-depth-2',
|
||||
provider.comingSoon
|
||||
? 'border-bolt-elements-borderColor opacity-50 cursor-not-allowed'
|
||||
: 'border-bolt-elements-borderColor cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-bolt-elements-background-depth-1 flex items-center justify-center flex-shrink-0">
|
||||
<span
|
||||
className={classNames(
|
||||
provider.iconClass,
|
||||
provider.iconColor || 'text-bolt-elements-textPrimary',
|
||||
'text-2xl',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
||||
{provider.connected && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
|
||||
)}
|
||||
{provider.comingSoon && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-2">{provider.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{provider.features.slice(0, 3).map((feature, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 rounded bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{provider.features.length > 3 && (
|
||||
<span className="text-xs px-2 py-1 text-bolt-elements-textTertiary">
|
||||
+{provider.features.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providers.find((p) => p.id === selectedProvider);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If provider is not connected, show connection form
|
||||
if (!provider.connected) {
|
||||
if (selectedProvider === 'netlify') {
|
||||
return (
|
||||
<NetlifyConnectForm
|
||||
onSuccess={() => {
|
||||
handleDeploy('netlify');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Add Vercel connection form here if needed
|
||||
return <div>Vercel connection form coming soon...</div>;
|
||||
}
|
||||
|
||||
// If connected, show deployment confirmation
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
|
||||
<span
|
||||
className={classNames(
|
||||
provider.iconClass,
|
||||
provider.iconColor || 'text-bolt-elements-textPrimary',
|
||||
'text-3xl',
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Ready to deploy to your {provider.name} account</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Deployment Features:</h4>
|
||||
<ul className="space-y-2">
|
||||
{provider.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:check-circle text-green-500 mt-0.5" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedProvider(null)}
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeploy(selectedProvider as 'netlify' | 'vercel' | 'github')}
|
||||
disabled={isDeploying}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Deploying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:rocket-launch" />
|
||||
Deploy Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex flex-col max-h-[90vh]">
|
||||
<div className="flex-shrink-0">
|
||||
<DialogTitle className="text-xl font-bold mb-1">Deploy Your Project</DialogTitle>
|
||||
<DialogDescription className="mb-6">
|
||||
Choose a deployment platform to publish your project to the web
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 pr-2 -mr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-bolt-elements-textTertiary">
|
||||
{renderProviderContent()}
|
||||
</div>
|
||||
|
||||
{!selectedProvider && (
|
||||
<div className="flex-shrink-0 mt-6 pt-6 border-t border-bolt-elements-borderColor">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
|
||||
{/* GitHub Deployment Dialog */}
|
||||
{showGitHubDialog && githubFiles && (
|
||||
<GitHubDeploymentDialog
|
||||
isOpen={showGitHubDialog}
|
||||
onClose={() => setShowGitHubDialog(false)}
|
||||
projectName={githubProjectName}
|
||||
files={githubFiles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Enhanced Deploy Button with Quick Deploy Option
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This component provides both authenticated and quick deployment options
|
||||
*/
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useState } from 'react';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
|
||||
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
|
||||
import { QuickNetlifyDeploy } from '~/components/deploy/QuickNetlifyDeploy.client';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
interface EnhancedDeployButtonProps {
|
||||
onVercelDeploy?: () => Promise<void>;
|
||||
onNetlifyDeploy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const EnhancedDeployButton = ({ onVercelDeploy, onNetlifyDeploy }: EnhancedDeployButtonProps) => {
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const [activePreviewIndex] = useState(0);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'quick' | null>(null);
|
||||
const [showQuickDeploy, setShowQuickDeploy] = useState(false);
|
||||
const isStreaming = useStore(streamingState);
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
|
||||
const handleVercelDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
try {
|
||||
if (onVercelDeploy) {
|
||||
await onVercelDeploy();
|
||||
} else {
|
||||
await handleVercelDeploy();
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNetlifyDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('netlify');
|
||||
|
||||
try {
|
||||
if (onNetlifyDeploy) {
|
||||
await onNetlifyDeploy();
|
||||
} else {
|
||||
await handleNetlifyDeploy();
|
||||
}
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
disabled={isDeploying || !activePreview || isStreaming}
|
||||
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
||||
>
|
||||
{isDeploying ? `Deploying${deployingTo ? ` to ${deployingTo}` : ''}...` : 'Deploy'}
|
||||
<span className={classNames('i-ph:caret-down transition-transform')} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'z-[250]',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'py-1',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
{/* Quick Deploy Option - Always Available */}
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview}
|
||||
onClick={() => setShowQuickDeploy(true)}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/netlify"
|
||||
alt="Netlify Quick Deploy"
|
||||
/>
|
||||
<span className="absolute -top-1 -right-1 bg-green-500 text-white text-[8px] px-1 rounded">NEW</span>
|
||||
</div>
|
||||
<span className="mx-auto font-medium">Quick Deploy to Netlify (No Login)</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Separator className="h-px bg-bolt-elements-borderColor my-1" />
|
||||
|
||||
{/* Authenticated Netlify Deploy */}
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !netlifyConn.user}
|
||||
onClick={handleNetlifyDeployClick}
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/netlify"
|
||||
/>
|
||||
<span className="mx-auto">
|
||||
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
|
||||
</span>
|
||||
{netlifyConn.user && <NetlifyDeploymentLink />}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{/* Vercel Deploy */}
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !vercelConn.user}
|
||||
onClick={handleVercelDeployClick}
|
||||
>
|
||||
<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">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
|
||||
{vercelConn.user && <VercelDeploymentLink />}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{/* Cloudflare - Coming Soon */}
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/cloudflare"
|
||||
alt="cloudflare"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
{/* Quick Deploy Dialog */}
|
||||
<Dialog.Root open={showQuickDeploy} onOpenChange={setShowQuickDeploy}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-[999] animate-in fade-in-0" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[1000] w-full max-w-2xl animate-in fade-in-0 zoom-in-95">
|
||||
<div className="bg-bolt-elements-background rounded-lg shadow-xl border border-bolt-elements-borderColor">
|
||||
<div className="flex items-center justify-between p-4 border-b border-bolt-elements-borderColor">
|
||||
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h2>
|
||||
<Dialog.Close className="p-1 rounded hover:bg-bolt-elements-item-backgroundActive transition-colors">
|
||||
<span className="i-ph:x text-lg text-bolt-elements-textSecondary" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<QuickNetlifyDeploy />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* Quick Netlify Deployment Component
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This component provides a streamlined one-click deployment to Netlify
|
||||
* with automatic build detection and configuration.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { path } from '~/utils/path';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
|
||||
interface QuickDeployConfig {
|
||||
framework?: 'react' | 'vue' | 'angular' | 'svelte' | 'next' | 'nuxt' | 'gatsby' | 'static';
|
||||
buildCommand?: string;
|
||||
outputDirectory?: string;
|
||||
nodeVersion?: string;
|
||||
}
|
||||
|
||||
export function QuickNetlifyDeploy() {
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployUrl, setDeployUrl] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const detectFramework = async (): Promise<QuickDeployConfig> => {
|
||||
try {
|
||||
const container = await webcontainer;
|
||||
|
||||
// Read package.json to detect framework
|
||||
let packageJson: any = {};
|
||||
|
||||
try {
|
||||
const packageContent = await container.fs.readFile('/package.json', 'utf-8');
|
||||
packageJson = JSON.parse(packageContent);
|
||||
} catch {
|
||||
console.log('No package.json found, assuming static site');
|
||||
return {
|
||||
framework: 'static',
|
||||
buildCommand: '',
|
||||
outputDirectory: '/',
|
||||
nodeVersion: '18',
|
||||
};
|
||||
}
|
||||
|
||||
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
||||
const scripts = packageJson.scripts || {};
|
||||
|
||||
// Detect framework based on dependencies
|
||||
const config: QuickDeployConfig = {
|
||||
nodeVersion: '18',
|
||||
};
|
||||
|
||||
if (deps.next) {
|
||||
config.framework = 'next';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = '.next';
|
||||
} else if (deps.nuxt || deps.nuxt3) {
|
||||
config.framework = 'nuxt';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = '.output/public';
|
||||
} else if (deps.gatsby) {
|
||||
config.framework = 'gatsby';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = 'public';
|
||||
} else if (deps['@angular/core']) {
|
||||
config.framework = 'angular';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = 'dist';
|
||||
} else if (deps.vue) {
|
||||
config.framework = 'vue';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = 'dist';
|
||||
} else if (deps.svelte) {
|
||||
config.framework = 'svelte';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = 'public';
|
||||
} else if (deps.react) {
|
||||
config.framework = 'react';
|
||||
config.buildCommand = scripts.build || 'npm run build';
|
||||
config.outputDirectory = 'build';
|
||||
|
||||
// Check for Vite
|
||||
if (deps.vite) {
|
||||
config.outputDirectory = 'dist';
|
||||
}
|
||||
} else {
|
||||
config.framework = 'static';
|
||||
config.buildCommand = scripts.build || '';
|
||||
config.outputDirectory = '/';
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('Error detecting framework:', error);
|
||||
return {
|
||||
framework: 'static',
|
||||
buildCommand: '',
|
||||
outputDirectory: '/',
|
||||
nodeVersion: '18',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDeploy = async (): Promise<string | null> => {
|
||||
if (!currentChatId) {
|
||||
toast.error('No active project found');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
setDeployUrl(null);
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
// Detect framework and configuration
|
||||
const config = await detectFramework();
|
||||
|
||||
toast.info(`Detected ${config.framework || 'static'} project. Starting deployment...`);
|
||||
|
||||
// Create deployment artifact for visual feedback
|
||||
const deploymentId = `quick-deploy-${Date.now()}`;
|
||||
workbenchStore.addArtifact({
|
||||
id: deploymentId,
|
||||
messageId: deploymentId,
|
||||
title: 'Quick Netlify Deployment',
|
||||
type: 'standalone',
|
||||
});
|
||||
|
||||
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
|
||||
|
||||
// Build the project if needed
|
||||
if (config.buildCommand) {
|
||||
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' });
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'quick-netlify-build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: config.buildCommand,
|
||||
},
|
||||
};
|
||||
|
||||
artifact.runner.addAction(actionData);
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
deployArtifact.runner.handleDeployAction('building', 'failed', {
|
||||
error: 'Build failed. Check the terminal for details.',
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare deployment
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' });
|
||||
|
||||
const container = await webcontainer;
|
||||
|
||||
// Determine the output directory
|
||||
let outputPath = config.outputDirectory || '/';
|
||||
|
||||
if (artifact.runner.buildOutput && artifact.runner.buildOutput.path) {
|
||||
outputPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
}
|
||||
|
||||
// Collect files for deployment
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
// Skip node_modules and other build artifacts
|
||||
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.cache') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
try {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
const deployPath = fullPath.replace(outputPath, '');
|
||||
files[deployPath] = content;
|
||||
} catch (e) {
|
||||
console.warn(`Could not read file ${fullPath}:`, e);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error reading directory ${dirPath}:`, e);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(outputPath);
|
||||
|
||||
// Create netlify.toml configuration
|
||||
const netlifyConfig = `
|
||||
[build]
|
||||
publish = "${config.outputDirectory || '/'}"
|
||||
${config.buildCommand ? `command = "${config.buildCommand}"` : ''}
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "${config.nodeVersion}"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
`;
|
||||
|
||||
fileContents['/netlify.toml'] = netlifyConfig;
|
||||
|
||||
// Deploy to Netlify using the quick deploy endpoint
|
||||
const response = await fetch('/api/netlify-quick-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: fileContents,
|
||||
chatId: currentChatId,
|
||||
framework: config.framework,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { success: boolean; url?: string; siteId?: string; error?: string };
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: data.error || 'Deployment failed',
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error(data.error || 'Deployment failed');
|
||||
}
|
||||
|
||||
// Deployment successful
|
||||
setDeployUrl(data.url || null);
|
||||
|
||||
deployArtifact.runner.handleDeployAction('complete', 'complete', {
|
||||
url: data.url || '',
|
||||
source: 'netlify',
|
||||
});
|
||||
|
||||
toast.success('Deployment successful! Your app is live.');
|
||||
|
||||
// Store deployment info
|
||||
if (data.siteId) {
|
||||
localStorage.setItem(`netlify-quick-site-${currentChatId}`, data.siteId);
|
||||
}
|
||||
|
||||
return data.url || null;
|
||||
} catch (error) {
|
||||
console.error('Quick deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img className="w-5 h-5" src="https://cdn.simpleicons.org/netlify" alt="Netlify" />
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h3>
|
||||
</div>
|
||||
{deployUrl && (
|
||||
<a
|
||||
href={deployUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-bolt-elements-link-text hover:text-bolt-elements-link-textHover underline"
|
||||
>
|
||||
View Live Site →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Deploy your project to Netlify instantly with automatic framework detection and configuration.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleQuickDeploy}
|
||||
disabled={isDeploying}
|
||||
className="px-6 py-3 rounded-lg bg-accent-500 text-white font-medium hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
<span className="i-ph:spinner-gap animate-spin w-5 h-5" />
|
||||
Deploying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:rocket-launch w-5 h-5" />
|
||||
Deploy Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{deployUrl && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
✅ Your app is live at:{' '}
|
||||
<a href={deployUrl} target="_blank" rel="noopener noreferrer" className="underline font-medium">
|
||||
{deployUrl}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span className={`i-ph:caret-right transform transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
|
||||
Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-2 text-sm text-bolt-elements-textSecondary space-y-2">
|
||||
<p>• Automatic framework detection (React, Vue, Next.js, etc.)</p>
|
||||
<p>• Smart build command configuration</p>
|
||||
<p>• Optimized output directory selection</p>
|
||||
<p>• SSL/HTTPS enabled by default</p>
|
||||
<p>• Global CDN distribution</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { classNames } from '~/utils/classNames';
|
||||
import { HeaderActionButtons } from './HeaderActionButtons.client';
|
||||
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
||||
|
||||
export function Header({ children }: { children?: React.ReactNode }) {
|
||||
export function Header() {
|
||||
const chat = useStore(chatStore);
|
||||
|
||||
return (
|
||||
@@ -37,8 +37,6 @@ export function Header({ children }: { children?: React.ReactNode }) {
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
{!chat.started && <div className="flex-1" />}
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { DeployButton } from '~/components/deploy/DeployButton';
|
||||
import { MultiUserToggle } from '~/components/multiuser/MultiUserToggle';
|
||||
|
||||
interface HeaderActionButtonsProps {
|
||||
chatStarted: boolean;
|
||||
@@ -16,10 +15,7 @@ export function HeaderActionButtons({ chatStarted: _chatStarted }: HeaderActionB
|
||||
const shouldShowButtons = activePreview;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Multi-User Sessions Toggle (Bolt.gives Exclusive) */}
|
||||
<MultiUserToggle />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Deploy Button */}
|
||||
{shouldShowButtons && <DeployButton />}
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore, logout } from '~/lib/stores/auth';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
export function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/auth');
|
||||
};
|
||||
|
||||
const handleManageUsers = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/admin/users');
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
setIsOpen(false);
|
||||
|
||||
// Open settings modal or navigate to settings
|
||||
};
|
||||
|
||||
if (!authState.isAuthenticated || !authState.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
{/* User Avatar Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
|
||||
{authState.user.avatar ? (
|
||||
<img src={authState.user.avatar} alt={authState.user.firstName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{authState.user.firstName[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">@{authState.user.username}</p>
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
'i-ph:caret-down text-bolt-elements-textSecondary transition-transform',
|
||||
isOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={classNames(
|
||||
'absolute right-0 mt-2 w-64',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'rounded-lg shadow-lg',
|
||||
'overflow-hidden',
|
||||
'z-50',
|
||||
)}
|
||||
>
|
||||
{/* User Info */}
|
||||
<div className="p-4 border-b border-bolt-elements-borderColor">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
|
||||
{authState.user.avatar ? (
|
||||
<img
|
||||
src={authState.user.avatar}
|
||||
alt={authState.user.firstName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{authState.user.firstName[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{authState.user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={handleSettings}
|
||||
className={classNames(
|
||||
'w-full px-4 py-2 text-left',
|
||||
'text-sm text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'transition-colors',
|
||||
'flex items-center gap-3',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:gear text-lg" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleManageUsers}
|
||||
className={classNames(
|
||||
'w-full px-4 py-2 text-left',
|
||||
'text-sm text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'transition-colors',
|
||||
'flex items-center gap-3',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:users text-lg" />
|
||||
<span>Manage Users</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t border-bolt-elements-borderColor" />
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={classNames(
|
||||
'w-full px-4 py-2 text-left',
|
||||
'text-sm text-red-500',
|
||||
'hover:bg-red-500/10',
|
||||
'transition-colors',
|
||||
'flex items-center gap-3',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:sign-out text-lg" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 bg-bolt-elements-background-depth-2 border-t border-bolt-elements-borderColor">
|
||||
<p className="text-xs text-bolt-elements-textTertiary">
|
||||
Member since {new Date(authState.user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ImportProjectDialog } from './ImportProjectDialog';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
export const ImportProjectButton: React.FC = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Add keyboard shortcut
|
||||
useHotkeys('ctrl+shift+i, cmd+shift+i', (e) => {
|
||||
e.preventDefault();
|
||||
setIsDialogOpen(true);
|
||||
});
|
||||
|
||||
const handleImport = useCallback(async (files: Map<string, string>) => {
|
||||
try {
|
||||
console.log('[ImportProject] Starting import of', files.size, 'files');
|
||||
|
||||
// Add files to workbench
|
||||
for (const [path, content] of files.entries()) {
|
||||
// Ensure path starts with /
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
console.log('[ImportProject] Adding file:', normalizedPath);
|
||||
|
||||
// Add file to workbench file system
|
||||
workbenchStore.files.setKey(normalizedPath, {
|
||||
type: 'file',
|
||||
content,
|
||||
isBinary: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Open the first file in the editor if any
|
||||
const firstFile = Array.from(files.keys())[0];
|
||||
|
||||
if (firstFile) {
|
||||
const normalizedPath = firstFile.startsWith('/') ? firstFile : `/${firstFile}`;
|
||||
workbenchStore.setSelectedFile(normalizedPath);
|
||||
}
|
||||
|
||||
toast.success(`Successfully imported ${files.size} files`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('[ImportProject] Import failed:', error);
|
||||
toast.error('Failed to import project files', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text transition-colors duration-200"
|
||||
title="Import existing project (Ctrl+Shift+I)"
|
||||
>
|
||||
<div className="i-ph:upload-simple text-lg" />
|
||||
<span className="text-sm font-medium">Import Project</span>
|
||||
</button>
|
||||
|
||||
<ImportProjectDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onImport={handleImport} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,567 +0,0 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import JSZip from 'jszip';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface ImportProjectDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImport?: (files: Map<string, string>) => void;
|
||||
}
|
||||
|
||||
interface FileStructure {
|
||||
[path: string]: string | ArrayBuffer;
|
||||
}
|
||||
|
||||
interface ImportStats {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
fileTypes: Map<string, number>;
|
||||
directories: Set<string>;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max per file
|
||||
const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB max total
|
||||
|
||||
const IGNORED_PATTERNS = [
|
||||
/node_modules\//,
|
||||
/\.git\//,
|
||||
/\.next\//,
|
||||
/dist\//,
|
||||
/build\//,
|
||||
/\.cache\//,
|
||||
/\.vscode\//,
|
||||
/\.idea\//,
|
||||
/\.DS_Store$/,
|
||||
/Thumbs\.db$/,
|
||||
/\.env\.local$/,
|
||||
/\.env\.production$/,
|
||||
];
|
||||
|
||||
const BINARY_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.pdf',
|
||||
'.zip',
|
||||
'.tar',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.exe',
|
||||
'.dll',
|
||||
'.so',
|
||||
'.dylib',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.eot',
|
||||
];
|
||||
|
||||
export const ImportProjectDialog: React.FC<ImportProjectDialogProps> = ({ isOpen, onClose, onImport }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importStats, setImportStats] = useState<ImportStats | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileStructure>({});
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setSelectedFiles({});
|
||||
setImportStats(null);
|
||||
setImportProgress(0);
|
||||
setErrorMessage(null);
|
||||
setIsProcessing(false);
|
||||
}, []);
|
||||
|
||||
const shouldIgnoreFile = (path: string): boolean => {
|
||||
return IGNORED_PATTERNS.some((pattern) => pattern.test(path));
|
||||
};
|
||||
|
||||
const isBinaryFile = (filename: string): boolean => {
|
||||
return BINARY_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext));
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const processZipFile = async (file: File): Promise<FileStructure> => {
|
||||
const zip = new JSZip();
|
||||
const zipData = await zip.loadAsync(file);
|
||||
const files: FileStructure = {};
|
||||
const stats: ImportStats = {
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
fileTypes: new Map(),
|
||||
directories: new Set(),
|
||||
};
|
||||
|
||||
const filePromises: Promise<void>[] = [];
|
||||
|
||||
zipData.forEach((relativePath, zipEntry) => {
|
||||
if (!zipEntry.dir && !shouldIgnoreFile(relativePath)) {
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const content = await zipEntry.async(isBinaryFile(relativePath) ? 'arraybuffer' : 'string');
|
||||
files[relativePath] = content;
|
||||
|
||||
stats.totalFiles++;
|
||||
|
||||
// Use a safe method to get uncompressed size
|
||||
const size = (zipEntry as any)._data?.uncompressedSize || 0;
|
||||
stats.totalSize += size;
|
||||
|
||||
const ext = relativePath.split('.').pop() || 'unknown';
|
||||
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
|
||||
|
||||
const dir = relativePath.substring(0, relativePath.lastIndexOf('/'));
|
||||
|
||||
if (dir) {
|
||||
stats.directories.add(dir);
|
||||
}
|
||||
|
||||
setImportProgress((prev) => Math.min(prev + 100 / Object.keys(zipData.files).length, 100));
|
||||
} catch (err) {
|
||||
console.error(`Failed to process ${relativePath}:`, err);
|
||||
}
|
||||
})();
|
||||
filePromises.push(promise);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
setImportStats(stats);
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const processFileList = async (fileList: FileList): Promise<FileStructure> => {
|
||||
const files: FileStructure = {};
|
||||
const stats: ImportStats = {
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
fileTypes: new Map(),
|
||||
directories: new Set(),
|
||||
};
|
||||
|
||||
let totalSize = 0;
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i];
|
||||
const path = (file as any).webkitRelativePath || file.name;
|
||||
|
||||
if (shouldIgnoreFile(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.warning(`Skipping ${file.name}: File too large (${formatFileSize(file.size)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSize += file.size;
|
||||
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
toast.error('Total size exceeds 200MB limit');
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await (isBinaryFile(file.name) ? file.arrayBuffer() : file.text());
|
||||
|
||||
files[path] = content;
|
||||
stats.totalFiles++;
|
||||
stats.totalSize += file.size;
|
||||
|
||||
const ext = file.name.split('.').pop() || 'unknown';
|
||||
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
|
||||
|
||||
const dir = path.substring(0, path.lastIndexOf('/'));
|
||||
|
||||
if (dir) {
|
||||
stats.directories.add(dir);
|
||||
}
|
||||
|
||||
setImportProgress(((i + 1) / fileList.length) * 100);
|
||||
} catch (err) {
|
||||
console.error(`Failed to read ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
setImportStats(stats);
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setErrorMessage(null);
|
||||
setImportProgress(0);
|
||||
|
||||
try {
|
||||
let processedFiles: FileStructure = {};
|
||||
|
||||
if (files.length === 1 && files[0].name.endsWith('.zip')) {
|
||||
processedFiles = await processZipFile(files[0]);
|
||||
} else {
|
||||
processedFiles = await processFileList(files);
|
||||
}
|
||||
|
||||
if (Object.keys(processedFiles).length === 0) {
|
||||
toast.warning('No valid files found to import');
|
||||
setIsProcessing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(processedFiles);
|
||||
toast.info(`Ready to import ${Object.keys(processedFiles).length} files`);
|
||||
} catch (error) {
|
||||
console.error('Error processing files:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to process files');
|
||||
toast.error('Failed to process files');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setImportProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
|
||||
if (files.length > 0) {
|
||||
const input = fileInputRef.current;
|
||||
|
||||
if (input) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(files).forEach((file) => dataTransfer.items.add(file));
|
||||
input.files = dataTransfer.files;
|
||||
handleFileSelect({ target: input } as any);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getFileExtension = (filename: string): string => {
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'file';
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string): string => {
|
||||
const ext = getFileExtension(filename);
|
||||
const iconMap: { [key: string]: string } = {
|
||||
js: 'i-vscode-icons:file-type-js',
|
||||
jsx: 'i-vscode-icons:file-type-reactjs',
|
||||
ts: 'i-vscode-icons:file-type-typescript',
|
||||
tsx: 'i-vscode-icons:file-type-reactts',
|
||||
css: 'i-vscode-icons:file-type-css',
|
||||
scss: 'i-vscode-icons:file-type-scss',
|
||||
html: 'i-vscode-icons:file-type-html',
|
||||
json: 'i-vscode-icons:file-type-json',
|
||||
md: 'i-vscode-icons:file-type-markdown',
|
||||
py: 'i-vscode-icons:file-type-python',
|
||||
vue: 'i-vscode-icons:file-type-vue',
|
||||
svg: 'i-vscode-icons:file-type-svg',
|
||||
git: 'i-vscode-icons:file-type-git',
|
||||
folder: 'i-vscode-icons:default-folder',
|
||||
};
|
||||
|
||||
return iconMap[ext] || 'i-vscode-icons:default-file';
|
||||
};
|
||||
|
||||
const handleImportClick = useCallback(async () => {
|
||||
if (Object.keys(selectedFiles).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const fileMap = new Map<string, string>();
|
||||
|
||||
for (const [path, content] of Object.entries(selectedFiles)) {
|
||||
if (typeof content === 'string') {
|
||||
fileMap.set(path, content);
|
||||
} else if (content instanceof ArrayBuffer) {
|
||||
// Convert ArrayBuffer to base64 string for binary files
|
||||
const bytes = new Uint8Array(content);
|
||||
const binary = String.fromCharCode(...bytes);
|
||||
const base64 = btoa(binary);
|
||||
fileMap.set(path, base64);
|
||||
}
|
||||
}
|
||||
|
||||
if (onImport) {
|
||||
// Use the provided onImport callback
|
||||
await onImport(fileMap);
|
||||
}
|
||||
|
||||
toast.success(`Successfully imported ${importStats?.totalFiles || 0} files`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
resetState();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Failed to import project', { position: 'bottom-right' });
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Import failed');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [selectedFiles, importStats, onImport, onClose, resetState]);
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
|
||||
<Dialog className="max-w-3xl" showCloseButton={false}>
|
||||
<div className="p-6">
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<div className="i-ph:upload-duotone text-3xl text-accent-500" />
|
||||
Import Existing Project
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload your project files or drag and drop them here. Supports individual files, folders, or ZIP archives.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="mt-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{!Object.keys(selectedFiles).length ? (
|
||||
<motion.div
|
||||
key="dropzone"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={classNames(
|
||||
'relative border-2 border-dashed rounded-xl p-12 text-center transition-all duration-200',
|
||||
isDragging
|
||||
? 'border-accent-500 bg-accent-500/10 scale-[1.02]'
|
||||
: 'border-bolt-elements-borderColor hover:border-accent-400/50',
|
||||
isProcessing ? 'pointer-events-none opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
{...({ webkitdirectory: 'true', directory: 'true' } as any)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<motion.div
|
||||
animate={isDragging ? { scale: 1.1, rotate: 5 } : { scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
className="i-ph:cloud-arrow-up-duotone text-6xl text-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">
|
||||
{isDragging ? 'Drop your project here' : 'Drag & Drop your project'}
|
||||
</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Support for folders, multiple files, or ZIP archives
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isProcessing}
|
||||
className="px-6 py-2.5 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Browse Files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.zip';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (target.files) {
|
||||
handleFileSelect({ target } as any);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
className="px-6 py-2.5 bg-transparent border border-bolt-elements-borderColor hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textPrimary rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Upload ZIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bolt-elements-background-depth-1/80 rounded-xl">
|
||||
<div className="text-center">
|
||||
<div className="i-svg-spinners:3-dots-scale text-4xl text-accent-500 mb-2" />
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Processing files... {Math.round(importProgress)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-red-400">{errorMessage}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="preview"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{importStats && (
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-bolt-elements-item-backgroundActive rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Total Files</p>
|
||||
<p className="text-lg font-semibold text-bolt-elements-textPrimary">{importStats.totalFiles}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Total Size</p>
|
||||
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
{formatFileSize(importStats.totalSize)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Directories</p>
|
||||
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
{importStats.directories.size}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-bolt-elements-borderColor rounded-lg overflow-hidden">
|
||||
<div className="bg-bolt-elements-background-depth-2 px-4 py-2 border-b border-bolt-elements-borderColor">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Files to Import</h4>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{Object.keys(selectedFiles)
|
||||
.slice(0, 50)
|
||||
.map((path, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 px-4 py-2 hover:bg-bolt-elements-item-backgroundActive transition-colors"
|
||||
>
|
||||
<div className={getFileIcon(path)} />
|
||||
<span className="text-sm text-bolt-elements-textPrimary truncate">{path}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(selectedFiles).length > 50 && (
|
||||
<div className="px-4 py-2 text-sm text-bolt-elements-textSecondary">
|
||||
... and {Object.keys(selectedFiles).length - 50} more files
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetState();
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm border border-bolt-elements-borderColor rounded-lg hover:bg-bolt-elements-item-backgroundActive transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportClick}
|
||||
disabled={isProcessing}
|
||||
className="px-6 py-2 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale mr-2" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
`Import ${Object.keys(selectedFiles).length} Files`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,346 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Dialog } from '~/components/ui/Dialog';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { toast } from 'react-toastify';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'developer' | 'viewer' | 'guest';
|
||||
status: 'active' | 'idle' | 'offline';
|
||||
lastActivity: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
lastActivity: string;
|
||||
ipAddress: string;
|
||||
device: string;
|
||||
}
|
||||
|
||||
export const MultiUserSessionManager: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeUsers, setActiveUsers] = useState<User[]>([]);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'developer' | 'viewer'>('developer');
|
||||
|
||||
useEffect(() => {
|
||||
loadSessionData();
|
||||
|
||||
const interval = setInterval(loadSessionData, 5000);
|
||||
|
||||
// Refresh every 5 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadSessionData = async () => {
|
||||
try {
|
||||
// Get current user
|
||||
const token = Cookies.get('auth_token');
|
||||
|
||||
if (token) {
|
||||
const userResponse = await fetch('/api/auth/verify', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setCurrentUser(userData as User);
|
||||
}
|
||||
}
|
||||
|
||||
// Get active users (mock data for demo)
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'dev@example.com',
|
||||
name: 'Developer',
|
||||
role: 'developer',
|
||||
status: 'idle',
|
||||
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
setActiveUsers(mockUsers);
|
||||
|
||||
// Get active sessions (mock data for demo)
|
||||
const mockSessions: Session[] = [
|
||||
{
|
||||
userId: '1',
|
||||
sessionId: 'session-1',
|
||||
startTime: new Date(Date.now() - 30 * 60000).toISOString(),
|
||||
lastActivity: new Date().toISOString(),
|
||||
ipAddress: '192.168.1.1',
|
||||
device: 'Chrome on Windows',
|
||||
},
|
||||
{
|
||||
userId: '2',
|
||||
sessionId: 'session-2',
|
||||
startTime: new Date(Date.now() - 60 * 60000).toISOString(),
|
||||
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
|
||||
ipAddress: '192.168.1.2',
|
||||
device: 'Safari on Mac',
|
||||
},
|
||||
];
|
||||
setSessions(mockSessions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load session data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error('Please enter an email address');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send invitation
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: inviteEmail,
|
||||
role: inviteRole,
|
||||
action: 'invite',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteEmail('');
|
||||
} else {
|
||||
toast.error('Failed to send invitation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invite error:', error);
|
||||
toast.error('Failed to send invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (userId: string) => {
|
||||
if (!window.confirm('Are you sure you want to remove this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('User removed successfully');
|
||||
loadSessionData();
|
||||
} else {
|
||||
toast.error('Failed to remove user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Remove user error:', error);
|
||||
toast.error('Failed to remove user');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* const handleTerminateSession = async (_sessionId: string) => {
|
||||
* if (!window.confirm('Are you sure you want to terminate this session?')) {
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* try {
|
||||
* // Terminate session
|
||||
* toast.success('Session terminated');
|
||||
* loadSessionData();
|
||||
* } catch (error) {
|
||||
* console.error('Terminate session error:', error);
|
||||
* toast.error('Failed to terminate session');
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
case 'developer':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case 'viewer':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'guest':
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
|
||||
default:
|
||||
return 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'i-ph:circle-fill text-green-400';
|
||||
case 'idle':
|
||||
return 'i-ph:circle-fill text-yellow-400';
|
||||
case 'offline':
|
||||
return 'i-ph:circle-fill text-gray-400';
|
||||
default:
|
||||
return 'i-ph:circle text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) {
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
if (diff < 3600) {
|
||||
return `${Math.floor(diff / 60)} min ago`;
|
||||
}
|
||||
|
||||
if (diff < 86400) {
|
||||
return `${Math.floor(diff / 3600)} hours ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(diff / 86400)} days ago`;
|
||||
};
|
||||
|
||||
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
|
||||
|
||||
if (!multiUserEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:bg-bolt-elements-background-depth-2 transition-all"
|
||||
title="Manage Sessions"
|
||||
>
|
||||
<span className="i-ph:users-three text-sm text-bolt-elements-textSecondary" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textPrimary">{activeUsers.length} Active</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog className="max-w-4xl" onClose={() => setIsOpen(false)}>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary mb-6">Multi-User Session Manager</h2>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 mb-6 border-b border-bolt-elements-borderColor">
|
||||
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textPrimary border-b-2 border-blue-500">
|
||||
Active Users ({activeUsers.length})
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
|
||||
Sessions ({sessions.length})
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
|
||||
Invite Users
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Users List */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{activeUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-4 bg-bolt-elements-background-depth-2 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<span className="text-white font-semibold">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">{user.name}</span>
|
||||
<span className={classNames('text-xs', getStatusIcon(user.status))} />
|
||||
<span
|
||||
className={classNames(
|
||||
'px-2 py-0.5 text-xs font-medium rounded-full border',
|
||||
getRoleBadgeColor(user.role),
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{user.email}</span>
|
||||
<span className="text-xs text-bolt-elements-textTertiary">
|
||||
Active {formatTimeAgo(user.lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{currentUser?.role === 'admin' && user.id !== currentUser.id && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRemoveUser(user.id)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
|
||||
title="Remove User"
|
||||
>
|
||||
<span className="i-ph:trash text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invite User Section */}
|
||||
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Invite New User</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Enter email address"
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary"
|
||||
/>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'developer' | 'viewer')}
|
||||
className="px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary"
|
||||
>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleInviteUser}
|
||||
className="bg-gradient-to-r from-green-500 to-blue-500"
|
||||
>
|
||||
Send Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,399 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Dialog } from '~/components/ui/Dialog';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MultiUserSessionManager } from './MultiUserSessionManager';
|
||||
|
||||
interface MultiUserToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MultiUserToggle: React.FC<MultiUserToggleProps> = ({ className }) => {
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [organizationName, setOrganizationName] = useState('');
|
||||
const [adminEmail, setAdminEmail] = useState('');
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [maxUsers, setMaxUsers] = useState('10');
|
||||
const [sessionTimeout, setSessionTimeout] = useState('30');
|
||||
const [allowGuestAccess, setAllowGuestAccess] = useState(false);
|
||||
|
||||
// Check if this is bolt.gives (exclusive feature)
|
||||
const isBoltGives = window.location.hostname === 'bolt.openweb.live' || window.location.hostname === 'localhost';
|
||||
|
||||
useEffect(() => {
|
||||
// Check if multi-user is already enabled
|
||||
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
|
||||
setIsEnabled(multiUserEnabled);
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isBoltGives) {
|
||||
toast.error('Multi-User Sessions is a Bolt.gives exclusive feature');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
// Show wizard to set up multi-user
|
||||
setShowWizard(true);
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
// Confirm disable
|
||||
if (window.confirm('Are you sure you want to disable Multi-User Sessions?')) {
|
||||
setIsEnabled(false);
|
||||
localStorage.setItem('multiUserEnabled', 'false');
|
||||
toast.success('Multi-User Sessions disabled');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === 1) {
|
||||
if (!organizationName.trim()) {
|
||||
toast.error('Please enter an organization name');
|
||||
return;
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
if (!adminEmail.trim() || !adminPassword.trim()) {
|
||||
toast.error('Please enter admin credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminPassword.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep < 4) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
// Complete setup
|
||||
handleCompleteSetup();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteSetup = async () => {
|
||||
try {
|
||||
// Save configuration
|
||||
const config = {
|
||||
organizationName,
|
||||
adminEmail,
|
||||
maxUsers: parseInt(maxUsers),
|
||||
sessionTimeout: parseInt(sessionTimeout),
|
||||
allowGuestAccess,
|
||||
enabled: true,
|
||||
setupDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Store in localStorage (in production, this would be server-side)
|
||||
localStorage.setItem('multiUserConfig', JSON.stringify(config));
|
||||
localStorage.setItem('multiUserEnabled', 'true');
|
||||
|
||||
// Create admin user
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
role: 'admin',
|
||||
organization: organizationName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsEnabled(true);
|
||||
setShowWizard(false);
|
||||
toast.success('Multi-User Sessions enabled successfully!');
|
||||
|
||||
// Auto-login the admin
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (loginResponse.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
const error = (await response.json()) as { message?: string };
|
||||
toast.error(error.message || 'Failed to setup Multi-User Sessions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to setup Multi-User Sessions';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isBoltGives) {
|
||||
return null; // Feature not available for non-bolt.gives deployments
|
||||
}
|
||||
|
||||
// If multi-user is enabled, show the session manager instead
|
||||
if (isEnabled) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<MultiUserSessionManager />
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="p-1.5 text-xs text-bolt-elements-textSecondary hover:text-red-400 transition-all"
|
||||
title="Disable Multi-User Sessions"
|
||||
>
|
||||
<span className="i-ph:power text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
isEnabled
|
||||
? 'bg-gradient-to-r from-green-500/20 to-blue-500/20 border-green-500/30'
|
||||
: 'bg-bolt-elements-background-depth-1',
|
||||
className,
|
||||
)}
|
||||
title={isEnabled ? 'Multi-User Sessions Active' : 'Enable Multi-User Sessions'}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'text-sm',
|
||||
isEnabled ? 'i-ph:users-three-fill text-green-400' : 'i-ph:users-three text-bolt-elements-textSecondary',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xs font-medium hidden sm:inline',
|
||||
isEnabled ? 'text-green-400' : 'text-bolt-elements-textSecondary',
|
||||
)}
|
||||
>
|
||||
{isEnabled ? 'Multi-User' : 'Single User'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showWizard && (
|
||||
<RadixDialog.Root open={showWizard} onOpenChange={setShowWizard}>
|
||||
<Dialog className="max-w-md" onClose={() => setShowWizard(false)}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Setup Multi-User Sessions</h2>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">Step {currentStep} of 4</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="flex gap-1 mb-6">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={classNames(
|
||||
'flex-1 h-1 rounded-full transition-all',
|
||||
step <= currentStep
|
||||
? 'bg-gradient-to-r from-green-500 to-blue-500'
|
||||
: 'bg-bolt-elements-borderColor',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Organization Setup */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Organization Setup</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Configure your organization for multi-user collaboration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
|
||||
Organization Name
|
||||
</label>
|
||||
<Input
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder="e.g., Acme Corp"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Admin Account */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Admin Account</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Create the administrator account for managing users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">Admin Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
|
||||
Admin Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
placeholder="Minimum 8 characters"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Session Settings */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Session Settings</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Configure session limits and security
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
|
||||
Maximum Concurrent Users
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxUsers}
|
||||
onChange={(e) => setMaxUsers(e.target.value)}
|
||||
min="2"
|
||||
max="100"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
|
||||
Session Timeout (minutes)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={sessionTimeout}
|
||||
onChange={(e) => setSessionTimeout(e.target.value)}
|
||||
min="5"
|
||||
max="1440"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowGuestAccess}
|
||||
onChange={(e) => setAllowGuestAccess(e.target.checked)}
|
||||
className="rounded border-bolt-elements-borderColor"
|
||||
/>
|
||||
<span className="text-sm text-bolt-elements-textPrimary">Allow guest access (read-only)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review & Confirm */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Review Configuration</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Please review your settings before enabling Multi-User Sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">Organization:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-medium">{organizationName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">Admin Email:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-medium">{adminEmail}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">Max Users:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-medium">{maxUsers}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">Session Timeout:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-medium">{sessionTimeout} minutes</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">Guest Access:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-medium">
|
||||
{allowGuestAccess ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<p className="text-xs text-blue-400">
|
||||
<span className="font-semibold">Note:</span> Multi-User Sessions is a Bolt.gives exclusive
|
||||
feature. You can manage users, sessions, and permissions from the admin panel after setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
} else {
|
||||
setShowWizard(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentStep === 1 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleNextStep}
|
||||
className="bg-gradient-to-r from-green-500 to-blue-500"
|
||||
>
|
||||
{currentStep === 4 ? 'Enable Multi-User' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,299 +0,0 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import * as Switch from '@radix-ui/react-switch';
|
||||
import * as Slider from '@radix-ui/react-slider';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface AutoSaveSettingsProps {
|
||||
onSettingsChange?: (settings: AutoSaveConfig) => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface AutoSaveConfig {
|
||||
enabled: boolean;
|
||||
interval: number; // in seconds
|
||||
minChanges: number;
|
||||
saveOnBlur: boolean;
|
||||
saveBeforeRun: boolean;
|
||||
showNotifications: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AutoSaveConfig = {
|
||||
enabled: false,
|
||||
interval: 30,
|
||||
minChanges: 1,
|
||||
saveOnBlur: true,
|
||||
saveBeforeRun: true,
|
||||
showNotifications: true,
|
||||
};
|
||||
|
||||
const PRESET_INTERVALS = [
|
||||
{ label: '10s', value: 10 },
|
||||
{ label: '30s', value: 30 },
|
||||
{ label: '1m', value: 60 },
|
||||
{ label: '2m', value: 120 },
|
||||
{ label: '5m', value: 300 },
|
||||
];
|
||||
|
||||
export const AutoSaveSettings = memo(({ onSettingsChange, trigger }: AutoSaveSettingsProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [config, setConfig] = useState<AutoSaveConfig>(() => {
|
||||
// Load from localStorage if available
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bolt-autosave-config');
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
// Invalid JSON, use defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_CONFIG;
|
||||
});
|
||||
|
||||
// Save to localStorage whenever config changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('bolt-autosave-config', JSON.stringify(config));
|
||||
}
|
||||
|
||||
onSettingsChange?.(config);
|
||||
}, [config, onSettingsChange]);
|
||||
|
||||
const updateConfig = <K extends keyof AutoSaveConfig>(key: K, value: AutoSaveConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
{trigger || (
|
||||
<button className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary transition-colors">
|
||||
<div className="i-ph:gear-duotone" />
|
||||
<span className="text-sm">Auto-save Settings</span>
|
||||
</button>
|
||||
)}
|
||||
</Dialog.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md"
|
||||
>
|
||||
<div className="bg-bolt-elements-background-depth-1 rounded-xl shadow-2xl border border-bolt-elements-borderColor">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-bolt-elements-borderColor">
|
||||
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
Auto-save Settings
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="p-1 rounded-lg hover:bg-bolt-elements-background-depth-2 transition-colors">
|
||||
<div className="i-ph:x text-xl text-bolt-elements-textTertiary" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Enable/Disable Auto-save */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">Enable Auto-save</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
Automatically save files at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) => updateConfig('enabled', checked)}
|
||||
className={classNames(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
config.enabled ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
|
||||
{/* Save Interval */}
|
||||
<div
|
||||
className={classNames(
|
||||
'space-y-3 transition-opacity',
|
||||
!config.enabled ? 'opacity-50 pointer-events-none' : '',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Save Interval: {config.interval}s
|
||||
</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">How often to save changes</p>
|
||||
</div>
|
||||
|
||||
<Slider.Root
|
||||
value={[config.interval]}
|
||||
onValueChange={([value]) => updateConfig('interval', value)}
|
||||
min={5}
|
||||
max={300}
|
||||
step={5}
|
||||
className="relative flex items-center select-none touch-none w-full h-5"
|
||||
>
|
||||
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
|
||||
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
|
||||
</Slider.Root>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex gap-2">
|
||||
{PRESET_INTERVALS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => updateConfig('interval', preset.value)}
|
||||
className={classNames(
|
||||
'px-2 py-1 text-xs rounded-md transition-colors',
|
||||
config.interval === preset.value
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary hover:bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minimum Changes */}
|
||||
<div
|
||||
className={classNames(
|
||||
'space-y-3 transition-opacity',
|
||||
!config.enabled ? 'opacity-50 pointer-events-none' : '',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Minimum Changes: {config.minChanges}
|
||||
</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
Minimum number of files to trigger auto-save
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Slider.Root
|
||||
value={[config.minChanges]}
|
||||
onValueChange={([value]) => updateConfig('minChanges', value)}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="relative flex items-center select-none touch-none w-full h-5"
|
||||
>
|
||||
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
|
||||
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
|
||||
</Slider.Root>
|
||||
</div>
|
||||
|
||||
{/* Additional Options */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Save on Tab Switch
|
||||
</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
Save when switching to another tab
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={config.saveOnBlur}
|
||||
onCheckedChange={(checked) => updateConfig('saveOnBlur', checked)}
|
||||
className={classNames(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
config.saveOnBlur ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">Save Before Run</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
Save all files before running commands
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={config.saveBeforeRun}
|
||||
onCheckedChange={(checked) => updateConfig('saveBeforeRun', checked)}
|
||||
className={classNames(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
config.saveBeforeRun ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Show Notifications
|
||||
</label>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
Display toast notifications on save
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={config.showNotifications}
|
||||
onCheckedChange={(checked) => updateConfig('showNotifications', checked)}
|
||||
className={classNames(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
config.showNotifications ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-bolt-elements-borderColor">
|
||||
<button
|
||||
onClick={() => setConfig(DEFAULT_CONFIG)}
|
||||
className="px-4 py-2 text-sm text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<Dialog.Close className="px-4 py-2 text-sm bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors">
|
||||
Done
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
});
|
||||
|
||||
AutoSaveSettings.displayName = 'AutoSaveSettings';
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface FileStatusIndicatorProps {
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const FileStatusIndicator = memo(({ className = '', showDetails = true }: FileStatusIndicatorProps) => {
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
const files = useStore(workbenchStore.files);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
let totalFiles = 0;
|
||||
let totalFolders = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
Object.entries(files).forEach(([_path, dirent]) => {
|
||||
if (dirent?.type === 'file') {
|
||||
totalFiles++;
|
||||
totalSize += dirent.content?.length || 0;
|
||||
} else if (dirent?.type === 'folder') {
|
||||
totalFolders++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalFiles,
|
||||
totalFolders,
|
||||
unsavedCount: unsavedFiles.size,
|
||||
totalSize: formatFileSize(totalSize),
|
||||
modifiedPercentage: totalFiles > 0 ? Math.round((unsavedFiles.size / totalFiles) * 100) : 0,
|
||||
};
|
||||
}, [files, unsavedFiles]);
|
||||
|
||||
function formatFileSize(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]}`;
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (stats.unsavedCount === 0) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
|
||||
if (stats.modifiedPercentage > 50) {
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
if (stats.modifiedPercentage > 20) {
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
|
||||
return 'text-orange-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center gap-4 px-3 py-1.5 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor',
|
||||
'text-xs text-bolt-elements-textTertiary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Status dot with pulse animation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: stats.unsavedCount > 0 ? [1, 1.2, 1] : 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: stats.unsavedCount > 0 ? Infinity : 0,
|
||||
repeatType: 'loop',
|
||||
}}
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
getStatusColor(),
|
||||
stats.unsavedCount > 0 ? 'bg-current' : 'bg-green-500',
|
||||
)}
|
||||
/>
|
||||
<span className={getStatusColor()}>
|
||||
{stats.unsavedCount === 0 ? 'All saved' : `${stats.unsavedCount} unsaved`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* File count */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="i-ph:file-duotone" />
|
||||
<span>{stats.totalFiles} files</span>
|
||||
</div>
|
||||
|
||||
{/* Folder count */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="i-ph:folder-duotone" />
|
||||
<span>{stats.totalFolders} folders</span>
|
||||
</div>
|
||||
|
||||
{/* Total size */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="i-ph:database-duotone" />
|
||||
<span>{stats.totalSize}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for unsaved files */}
|
||||
{stats.unsavedCount > 0 && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-xs">{stats.modifiedPercentage}% modified</span>
|
||||
<div className="w-20 h-1.5 bg-bolt-elements-background-depth-2 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${stats.modifiedPercentage}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={classNames(
|
||||
'h-full rounded-full',
|
||||
stats.modifiedPercentage > 50
|
||||
? 'bg-red-500'
|
||||
: stats.modifiedPercentage > 20
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-orange-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileStatusIndicator.displayName = 'FileStatusIndicator';
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
|
||||
export function useKeyboardSaveAll() {
|
||||
useEffect(() => {
|
||||
const handleKeyPress = async (e: KeyboardEvent) => {
|
||||
// Ctrl+Shift+S or Cmd+Shift+S to save all
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
|
||||
const unsavedFiles = workbenchStore.unsavedFiles.get();
|
||||
|
||||
if (unsavedFiles.size === 0) {
|
||||
toast.info('All files are already saved', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const count = unsavedFiles.size;
|
||||
await workbenchStore.saveAllFiles();
|
||||
|
||||
toast.success(`Saved ${count} file${count > 1 ? 's' : ''}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save some files', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, []);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface SaveAllButtonProps {
|
||||
className?: string;
|
||||
variant?: 'icon' | 'button';
|
||||
showCount?: boolean;
|
||||
autoSave?: boolean;
|
||||
autoSaveInterval?: number;
|
||||
}
|
||||
|
||||
export const SaveAllButton = memo(
|
||||
({
|
||||
className = '',
|
||||
variant = 'icon',
|
||||
showCount = true,
|
||||
autoSave = false,
|
||||
autoSaveInterval = 30000,
|
||||
}: SaveAllButtonProps) => {
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [timeUntilAutoSave, setTimeUntilAutoSave] = useState<number | null>(null);
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const unsavedCount = unsavedFiles.size;
|
||||
const hasUnsavedFiles = unsavedCount > 0;
|
||||
|
||||
// Log unsaved files state changes
|
||||
useEffect(() => {
|
||||
console.log('[SaveAllButton] Unsaved files changed:', {
|
||||
count: unsavedCount,
|
||||
files: Array.from(unsavedFiles),
|
||||
hasUnsavedFiles,
|
||||
});
|
||||
}, [unsavedFiles, unsavedCount, hasUnsavedFiles]);
|
||||
|
||||
// Auto-save logic
|
||||
useEffect(() => {
|
||||
if (!autoSave || !hasUnsavedFiles) {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
countdownTimerRef.current = null;
|
||||
}
|
||||
|
||||
setTimeUntilAutoSave(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up auto-save timer
|
||||
console.log('[SaveAllButton] Setting up auto-save timer for', autoSaveInterval, 'ms');
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
if (hasUnsavedFiles && !isSaving) {
|
||||
console.log('[SaveAllButton] Auto-save triggered');
|
||||
await handleSaveAll(true);
|
||||
}
|
||||
}, autoSaveInterval);
|
||||
|
||||
// Set up countdown timer
|
||||
const startTime = Date.now();
|
||||
setTimeUntilAutoSave(Math.ceil(autoSaveInterval / 1000));
|
||||
|
||||
countdownTimerRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, autoSaveInterval - elapsed);
|
||||
setTimeUntilAutoSave(Math.ceil(remaining / 1000));
|
||||
|
||||
if (remaining <= 0 && countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
countdownTimerRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
}, [autoSave, hasUnsavedFiles, autoSaveInterval, isSaving]);
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSaveAll = useCallback(
|
||||
async (isAutoSave = false) => {
|
||||
if (!hasUnsavedFiles || isSaving) {
|
||||
console.log('[SaveAllButton] Skipping save:', { hasUnsavedFiles, isSaving });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SaveAllButton] Starting save:', {
|
||||
unsavedCount,
|
||||
isAutoSave,
|
||||
files: Array.from(unsavedFiles),
|
||||
});
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const startTime = performance.now();
|
||||
const savedFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Save each file individually with detailed logging
|
||||
for (const filePath of unsavedFiles) {
|
||||
try {
|
||||
console.log(`[SaveAllButton] Saving file: ${filePath}`);
|
||||
await workbenchStore.saveFile(filePath);
|
||||
savedFiles.push(filePath);
|
||||
console.log(`[SaveAllButton] Successfully saved: ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error(`[SaveAllButton] Failed to save ${filePath}:`, error);
|
||||
failedFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = Math.round(endTime - startTime);
|
||||
setLastSaved(new Date());
|
||||
|
||||
// Check final state
|
||||
const remainingUnsaved = workbenchStore.unsavedFiles.get();
|
||||
console.log('[SaveAllButton] Save complete:', {
|
||||
savedCount: savedFiles.length,
|
||||
failedCount: failedFiles.length,
|
||||
remainingUnsaved: Array.from(remainingUnsaved),
|
||||
duration,
|
||||
});
|
||||
|
||||
// Show appropriate feedback
|
||||
if (failedFiles.length === 0) {
|
||||
const message = isAutoSave
|
||||
? `Auto-saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`
|
||||
: `Saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`;
|
||||
|
||||
toast.success(message, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} else {
|
||||
toast.warning(`Saved ${savedFiles.length} files, ${failedFiles.length} failed`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SaveAllButton] Critical error during save:', error);
|
||||
toast.error('Failed to save files', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[hasUnsavedFiles, isSaving, unsavedCount, unsavedFiles],
|
||||
);
|
||||
|
||||
// Keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSaveAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [handleSaveAll]);
|
||||
|
||||
const formatLastSaved = () => {
|
||||
if (!lastSaved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) {
|
||||
return `${diff}s ago`;
|
||||
}
|
||||
|
||||
if (diff < 3600) {
|
||||
return `${Math.floor(diff / 60)}m ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
};
|
||||
|
||||
// Icon-only variant for header
|
||||
if (variant === 'icon') {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={() => handleSaveAll(false)}
|
||||
disabled={!hasUnsavedFiles || isSaving}
|
||||
className={classNames(
|
||||
'relative p-1.5 rounded-md transition-colors',
|
||||
hasUnsavedFiles
|
||||
? 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive'
|
||||
: 'text-bolt-elements-textTertiary cursor-not-allowed opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={isSaving ? 'animate-spin' : ''}>
|
||||
<div className="i-ph:floppy-disk text-lg" />
|
||||
</div>
|
||||
{hasUnsavedFiles && showCount && !isSaving && (
|
||||
<div className="absolute -top-1 -right-1 min-w-[12px] h-[12px] bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] font-bold">
|
||||
{unsavedCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-semibold">
|
||||
{hasUnsavedFiles ? `${unsavedCount} unsaved file${unsavedCount > 1 ? 's' : ''}` : 'All files saved'}
|
||||
</div>
|
||||
{lastSaved && <div className="text-bolt-elements-textTertiary">Last saved: {formatLastSaved()}</div>}
|
||||
{autoSave && hasUnsavedFiles && timeUntilAutoSave && (
|
||||
<div className="text-bolt-elements-textTertiary">Auto-save in: {timeUntilAutoSave}s</div>
|
||||
)}
|
||||
<div className="border-t border-bolt-elements-borderColor pt-1 mt-1">
|
||||
<kbd className="text-xs">Ctrl+Shift+S</kbd> to save all
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Button variant
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={() => handleSaveAll(false)}
|
||||
disabled={!hasUnsavedFiles || isSaving}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
hasUnsavedFiles
|
||||
? 'bg-accent-500 hover:bg-accent-600 text-white'
|
||||
: 'bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary border border-bolt-elements-borderColor cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={isSaving ? 'animate-spin' : ''}>
|
||||
<div className="i-ph:floppy-disk" />
|
||||
</div>
|
||||
<span>
|
||||
{isSaving ? 'Saving...' : `Save All${showCount && hasUnsavedFiles ? ` (${unsavedCount})` : ''}`}
|
||||
</span>
|
||||
{autoSave && timeUntilAutoSave && hasUnsavedFiles && (
|
||||
<span className="text-xs opacity-75">({timeUntilAutoSave}s)</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="text-xs">
|
||||
<kbd>Ctrl+Shift+S</kbd> to save all
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SaveAllButton.displayName = 'SaveAllButton';
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
@@ -23,11 +22,13 @@ import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
|
||||
// import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { usePreviewStore } from '~/lib/stores/previews';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import type { ElementInfo } from './Inspector';
|
||||
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -279,48 +280,18 @@ const FileModifiedDropdown = memo(
|
||||
},
|
||||
);
|
||||
|
||||
export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }: WorkspaceProps) => {
|
||||
export const Workbench = memo(
|
||||
({
|
||||
chatStarted,
|
||||
isStreaming,
|
||||
metadata: _metadata,
|
||||
updateChatMestaData: _updateChatMestaData,
|
||||
setSelectedElement,
|
||||
}: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
||||
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
||||
|
||||
// Keyboard shortcut for Save All (Ctrl+Shift+S)
|
||||
useEffect(() => {
|
||||
const handleKeyPress = async (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
|
||||
const unsavedFiles = workbenchStore.unsavedFiles.get();
|
||||
|
||||
if (unsavedFiles.size > 0) {
|
||||
try {
|
||||
await workbenchStore.saveAllFiles();
|
||||
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save some files', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.info('All files are already saved', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, []);
|
||||
|
||||
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
||||
|
||||
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||
@@ -334,6 +305,9 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
const streaming = useStore(streamingState);
|
||||
const { exportChat } = useChatHistory();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const setSelectedView = (view: WorkbenchViewType) => {
|
||||
workbenchStore.currentView.set(view);
|
||||
@@ -378,6 +352,11 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
workbenchStore.resetCurrentDocument();
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = useCallback((filePath: string) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
workbenchStore.currentView.set('diff');
|
||||
}, []);
|
||||
|
||||
const handleSyncFiles = useCallback(async () => {
|
||||
setIsSyncing(true);
|
||||
|
||||
@@ -385,19 +364,14 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
const directoryHandle = await window.showDirectoryPicker();
|
||||
await workbenchStore.syncFiles(directoryHandle);
|
||||
toast.success('Files synced successfully');
|
||||
} catch {
|
||||
console.error('Error syncing files');
|
||||
} catch (error) {
|
||||
console.error('Error syncing files:', error);
|
||||
toast.error('Failed to sync files');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = useCallback((filePath: string) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
workbenchStore.currentView.set('diff');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
@@ -433,55 +407,18 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={async () => {
|
||||
console.log('[SaveAll] Button clicked');
|
||||
{/* Export Chat Button */}
|
||||
<ExportChatButton exportChat={exportChat} />
|
||||
|
||||
const unsavedFiles = workbenchStore.unsavedFiles.get();
|
||||
console.log('[SaveAll] Unsaved files:', Array.from(unsavedFiles));
|
||||
|
||||
if (unsavedFiles.size > 0) {
|
||||
try {
|
||||
console.log('[SaveAll] Starting save...');
|
||||
await workbenchStore.saveAllFiles();
|
||||
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
console.log('[SaveAll] Save successful');
|
||||
} catch {
|
||||
console.error('[SaveAll] Save failed');
|
||||
toast.error('Failed to save files', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('[SaveAll] No unsaved files');
|
||||
toast.info('All files are already saved', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 2000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:floppy-disk" />
|
||||
Save All
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:terminal" />
|
||||
Toggle Terminal
|
||||
</PanelHeaderButton>
|
||||
{/* Sync Button */}
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
|
||||
<div className="i-ph:box-arrow-up" />
|
||||
Sync
|
||||
<DropdownMenu.Trigger
|
||||
disabled={isSyncing || streaming}
|
||||
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
||||
>
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
<span className={classNames('i-ph:caret-down transition-transform')} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
@@ -503,26 +440,31 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{isSyncing ? (
|
||||
<div className="i-ph:spinner" />
|
||||
) : (
|
||||
<div className="i-ph:cloud-arrow-down" />
|
||||
)}
|
||||
onClick={() => {
|
||||
/* GitHub push temporarily disabled */
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-branch" />
|
||||
Push to GitHub
|
||||
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
{/* Toggle Terminal Button */}
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
||||
}}
|
||||
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
||||
>
|
||||
<div className="i-ph:terminal" />
|
||||
Toggle Terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === 'diff' && (
|
||||
@@ -566,11 +508,11 @@ export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }:
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* GitHub push dialog temporarily disabled during merge - will be re-enabled with new GitLab integration */}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// View component for rendering content with motion transitions
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* Stream Recovery Module
|
||||
* Handles stream failures and provides automatic recovery mechanisms
|
||||
* Fixes chat conversation hanging issues
|
||||
* Author: Keoma Wright
|
||||
*/
|
||||
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('stream-recovery');
|
||||
|
||||
export interface StreamRecoveryOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
timeout?: number;
|
||||
onRetry?: (attempt: number) => void;
|
||||
onTimeout?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export class StreamRecoveryManager {
|
||||
private _retryCount = 0;
|
||||
private _timeoutHandle: NodeJS.Timeout | null = null;
|
||||
private _lastActivity: number = Date.now();
|
||||
private _isActive = true;
|
||||
|
||||
constructor(private _options: StreamRecoveryOptions = {}) {
|
||||
this._options = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
timeout: 30000, // 30 seconds default timeout
|
||||
..._options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring the stream for inactivity
|
||||
*/
|
||||
startMonitoring() {
|
||||
this._resetTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timeout when activity is detected
|
||||
*/
|
||||
recordActivity() {
|
||||
this._lastActivity = Date.now();
|
||||
this._resetTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timeout timer
|
||||
*/
|
||||
private _resetTimeout() {
|
||||
if (this._timeoutHandle) {
|
||||
clearTimeout(this._timeoutHandle);
|
||||
}
|
||||
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._timeoutHandle = setTimeout(() => {
|
||||
const inactiveTime = Date.now() - this._lastActivity;
|
||||
logger.warn(`Stream timeout detected after ${inactiveTime}ms of inactivity`);
|
||||
|
||||
if (this._options.onTimeout) {
|
||||
this._options.onTimeout();
|
||||
}
|
||||
|
||||
this._handleTimeout();
|
||||
}, this._options.timeout!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream timeout
|
||||
*/
|
||||
private _handleTimeout() {
|
||||
logger.error('Stream timeout - attempting recovery');
|
||||
|
||||
// Signal that recovery is needed
|
||||
this.attemptRecovery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover from a stream failure
|
||||
*/
|
||||
async attemptRecovery(): Promise<boolean> {
|
||||
if (this._retryCount >= this._options.maxRetries!) {
|
||||
logger.error(`Max retries (${this._options.maxRetries}) reached - cannot recover`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._retryCount++;
|
||||
logger.info(`Attempting recovery (attempt ${this._retryCount}/${this._options.maxRetries})`);
|
||||
|
||||
if (this._options.onRetry) {
|
||||
this._options.onRetry(this._retryCount);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, this._options.retryDelay! * this._retryCount));
|
||||
|
||||
// Reset activity tracking
|
||||
this.recordActivity();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream errors with recovery
|
||||
*/
|
||||
async handleError(error: any): Promise<boolean> {
|
||||
logger.error('Stream error detected:', error);
|
||||
|
||||
if (this._options.onError) {
|
||||
this._options.onError(error);
|
||||
}
|
||||
|
||||
// Check if error is recoverable
|
||||
if (this._isRecoverableError(error)) {
|
||||
return await this.attemptRecovery();
|
||||
}
|
||||
|
||||
logger.error('Non-recoverable error - cannot continue');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable
|
||||
*/
|
||||
private _isRecoverableError(error: any): boolean {
|
||||
const errorMessage = error?.message || error?.toString() || '';
|
||||
|
||||
// List of recoverable error patterns
|
||||
const recoverablePatterns = [
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'socket hang up',
|
||||
'network',
|
||||
'timeout',
|
||||
'abort',
|
||||
'EPIPE',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'rate limit',
|
||||
];
|
||||
|
||||
return recoverablePatterns.some((pattern) => errorMessage.toLowerCase().includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring and cleanup
|
||||
*/
|
||||
stop() {
|
||||
this._isActive = false;
|
||||
|
||||
if (this._timeoutHandle) {
|
||||
clearTimeout(this._timeoutHandle);
|
||||
this._timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the recovery manager
|
||||
*/
|
||||
reset() {
|
||||
this._retryCount = 0;
|
||||
this._lastActivity = Date.now();
|
||||
this._isActive = true;
|
||||
this._resetTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wrapped stream with recovery capabilities
|
||||
*/
|
||||
export function createRecoverableStream<T>(
|
||||
streamFactory: () => Promise<ReadableStream<T>>,
|
||||
options?: StreamRecoveryOptions,
|
||||
): ReadableStream<T> {
|
||||
const recovery = new StreamRecoveryManager(options);
|
||||
let currentStream: ReadableStream<T> | null = null;
|
||||
let reader: ReadableStreamDefaultReader<T> | null = null;
|
||||
|
||||
return new ReadableStream<T>({
|
||||
async start(controller) {
|
||||
recovery.startMonitoring();
|
||||
|
||||
try {
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create initial stream:', error);
|
||||
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (canRecover) {
|
||||
// Retry creating the stream
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
} else {
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async pull(controller) {
|
||||
if (!reader) {
|
||||
controller.error(new Error('No reader available'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
recovery.stop();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Record activity to reset timeout
|
||||
recovery.recordActivity();
|
||||
controller.enqueue(value);
|
||||
} catch (error) {
|
||||
logger.error('Error reading from stream:', error);
|
||||
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (canRecover) {
|
||||
// Try to recreate the stream
|
||||
try {
|
||||
if (reader) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
|
||||
// Continue reading
|
||||
await this.pull!(controller);
|
||||
} catch (retryError) {
|
||||
logger.error('Recovery failed:', retryError);
|
||||
controller.error(retryError);
|
||||
recovery.stop();
|
||||
}
|
||||
} else {
|
||||
controller.error(error);
|
||||
recovery.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
recovery.stop();
|
||||
|
||||
if (reader) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,65 +11,6 @@ import { createFilesContext, extractPropertiesFromMessage } from './utils';
|
||||
import { discussPrompt } from '~/lib/common/prompts/discuss-prompt';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
|
||||
function getSmartAISystemPrompt(basePrompt: string): string {
|
||||
const smartAIEnhancement = `
|
||||
## SmartAI Mode - Enhanced Conversational Coding Assistant
|
||||
|
||||
You are operating in SmartAI mode, a premium Bolt.gives feature that provides detailed, educational feedback throughout the coding process.
|
||||
|
||||
### Your Communication Style:
|
||||
- Be conversational and friendly, as if pair programming with a colleague
|
||||
- Explain your thought process clearly and educationally
|
||||
- Use natural language, not technical jargon unless necessary
|
||||
- Keep responses visible and engaging
|
||||
|
||||
### What to Communicate:
|
||||
|
||||
**When Starting Tasks:**
|
||||
✨ "I see you want [task description]. Let me [approach explanation]..."
|
||||
✨ Explain your understanding and planned approach
|
||||
✨ Share why you're choosing specific solutions
|
||||
|
||||
**During Implementation:**
|
||||
📝 "Now I'm creating/updating [file] to [purpose]..."
|
||||
📝 Explain what each code section does
|
||||
📝 Share the patterns and best practices you're using
|
||||
📝 Discuss any trade-offs or alternatives considered
|
||||
|
||||
**When Problem-Solving:**
|
||||
🔍 "I noticed [issue]. This is likely because [reasoning]..."
|
||||
🔍 Share your debugging thought process
|
||||
🔍 Explain how you're identifying and fixing issues
|
||||
🔍 Describe why your solution will work
|
||||
|
||||
**After Completing Work:**
|
||||
✅ "I've successfully [what was done]. The key changes include..."
|
||||
✅ Summarize what was accomplished
|
||||
✅ Highlight important decisions made
|
||||
✅ Suggest potential improvements or next steps
|
||||
|
||||
### Example Responses:
|
||||
|
||||
Instead of silence:
|
||||
"I understand you need a contact form. Let me create a modern, accessible form with proper validation. I'll start by setting up the form structure with semantic HTML..."
|
||||
|
||||
While coding:
|
||||
"I'm now adding email validation to ensure users enter valid email addresses. I'll use a regex pattern that covers most common email formats while keeping it user-friendly..."
|
||||
|
||||
When debugging:
|
||||
"I see the button isn't aligning properly with the other elements. This looks like a flexbox issue. Let me adjust the container's display properties to fix the alignment..."
|
||||
|
||||
### Remember:
|
||||
- Users chose SmartAI to learn from your process
|
||||
- Make every action visible and understandable
|
||||
- Be their coding companion, not just a silent worker
|
||||
- Keep the conversation flowing naturally
|
||||
|
||||
${basePrompt}`;
|
||||
|
||||
return smartAIEnhancement;
|
||||
}
|
||||
|
||||
export type Messages = Message[];
|
||||
|
||||
export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
|
||||
@@ -141,19 +82,13 @@ export async function streamText(props: {
|
||||
} = props;
|
||||
let currentModel = DEFAULT_MODEL;
|
||||
let currentProvider = DEFAULT_PROVIDER.name;
|
||||
let smartAIEnabled = false;
|
||||
let processedMessages = messages.map((message) => {
|
||||
const newMessage = { ...message };
|
||||
|
||||
if (message.role === 'user') {
|
||||
const { model, provider, content, smartAI } = extractPropertiesFromMessage(message);
|
||||
const { model, provider, content } = extractPropertiesFromMessage(message);
|
||||
currentModel = model;
|
||||
currentProvider = provider;
|
||||
|
||||
if (smartAI !== undefined) {
|
||||
smartAIEnabled = smartAI;
|
||||
}
|
||||
|
||||
newMessage.content = sanitizeText(content);
|
||||
} else if (message.role == 'assistant') {
|
||||
newMessage.content = sanitizeText(message.content);
|
||||
@@ -207,39 +142,13 @@ export async function streamText(props: {
|
||||
|
||||
const dynamicMaxTokens = modelDetails ? getCompletionTokenLimit(modelDetails) : Math.min(MAX_TOKENS, 16384);
|
||||
|
||||
// Additional safety cap - respect model-specific limits
|
||||
let safeMaxTokens = dynamicMaxTokens;
|
||||
|
||||
// Apply model-specific caps for Anthropic models
|
||||
if (modelDetails?.provider === 'Anthropic') {
|
||||
if (modelDetails.name.includes('claude-sonnet-4') || modelDetails.name.includes('claude-opus-4')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
|
||||
} else if (modelDetails.name.includes('claude-3-7-sonnet')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
|
||||
} else if (modelDetails.name.includes('claude-3-5-sonnet')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 8192);
|
||||
} else {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 4096);
|
||||
}
|
||||
} else {
|
||||
// General safety cap for other providers
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 128000);
|
||||
}
|
||||
// Use model-specific limits directly - no artificial cap needed
|
||||
const safeMaxTokens = dynamicMaxTokens;
|
||||
|
||||
logger.info(
|
||||
`Max tokens for model ${modelDetails.name} is ${safeMaxTokens} (capped from ${dynamicMaxTokens}) based on model limits`,
|
||||
`Token limits for model ${modelDetails.name}: maxTokens=${safeMaxTokens}, maxTokenAllowed=${modelDetails.maxTokenAllowed}, maxCompletionTokens=${modelDetails.maxCompletionTokens}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* Check if SmartAI is enabled for supported models
|
||||
* SmartAI is enabled if either:
|
||||
* 1. The model itself has isSmartAIEnabled flag (for models with SmartAI in name)
|
||||
* 2. The user explicitly enabled it via message flag
|
||||
*/
|
||||
const isSmartAISupported =
|
||||
modelDetails?.supportsSmartAI && (provider.name === 'Anthropic' || provider.name === 'OpenAI');
|
||||
const useSmartAI = (modelDetails?.isSmartAIEnabled || smartAIEnabled) && isSmartAISupported;
|
||||
|
||||
let systemPrompt =
|
||||
PromptLibrary.getPropmtFromLibrary(promptId || 'default', {
|
||||
cwd: WORK_DIR,
|
||||
@@ -253,11 +162,6 @@ export async function streamText(props: {
|
||||
},
|
||||
}) ?? getSystemPrompt();
|
||||
|
||||
// Enhance system prompt for SmartAI if enabled and supported
|
||||
if (useSmartAI) {
|
||||
systemPrompt = getSmartAISystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
if (chatMode === 'build' && contextFiles && contextOptimization) {
|
||||
const codeContext = createFilesContext(contextFiles, true);
|
||||
|
||||
@@ -317,11 +221,18 @@ export async function streamText(props: {
|
||||
|
||||
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
|
||||
|
||||
// DEBUG: Log reasoning model detection
|
||||
// Log reasoning model detection and token parameters
|
||||
const isReasoning = isReasoningModel(modelDetails.name);
|
||||
logger.info(`DEBUG STREAM: Model "${modelDetails.name}" detected as reasoning model: ${isReasoning}`);
|
||||
logger.info(
|
||||
`Model "${modelDetails.name}" is reasoning model: ${isReasoning}, using ${isReasoning ? 'maxCompletionTokens' : 'maxTokens'}: ${safeMaxTokens}`,
|
||||
);
|
||||
|
||||
// console.log(systemPrompt, processedMessages);
|
||||
// Validate token limits before API call
|
||||
if (safeMaxTokens > (modelDetails.maxTokenAllowed || 128000)) {
|
||||
logger.warn(
|
||||
`Token limit warning: requesting ${safeMaxTokens} tokens but model supports max ${modelDetails.maxTokenAllowed || 128000}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Use maxCompletionTokens for reasoning models (o1, GPT-5), maxTokens for traditional models
|
||||
const tokenParams = isReasoning ? { maxCompletionTokens: safeMaxTokens } : { maxTokens: safeMaxTokens };
|
||||
|
||||
@@ -8,7 +8,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
model: string;
|
||||
provider: string;
|
||||
content: string;
|
||||
smartAI?: boolean;
|
||||
} {
|
||||
const textContent = Array.isArray(message.content)
|
||||
? message.content.find((item) => item.type === 'text')?.text || ''
|
||||
@@ -17,10 +16,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
const modelMatch = textContent.match(MODEL_REGEX);
|
||||
const providerMatch = textContent.match(PROVIDER_REGEX);
|
||||
|
||||
// Check for SmartAI toggle in the message
|
||||
const smartAIMatch = textContent.match(/\[SmartAI:(true|false)\]/);
|
||||
const smartAI = smartAIMatch ? smartAIMatch[1] === 'true' : undefined;
|
||||
|
||||
/*
|
||||
* Extract model
|
||||
* const modelMatch = message.content.match(MODEL_REGEX);
|
||||
@@ -38,21 +33,15 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
if (item.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: item.text
|
||||
?.replace(MODEL_REGEX, '')
|
||||
.replace(PROVIDER_REGEX, '')
|
||||
.replace(/\[SmartAI:(true|false)\]/g, ''),
|
||||
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
|
||||
};
|
||||
}
|
||||
|
||||
return item; // Preserve image_url and other types as is
|
||||
})
|
||||
: textContent
|
||||
.replace(MODEL_REGEX, '')
|
||||
.replace(PROVIDER_REGEX, '')
|
||||
.replace(/\[SmartAI:(true|false)\]/g, '');
|
||||
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
|
||||
return { model, provider, content: cleanedContent, smartAI };
|
||||
return { model, provider, content: cleanedContent };
|
||||
}
|
||||
|
||||
export function simplifyBoltActions(input: string): string {
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* Netlify Configuration Helper
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This module provides automatic configuration generation for Netlify deployments
|
||||
*/
|
||||
|
||||
export interface NetlifyConfig {
|
||||
build: {
|
||||
command?: string;
|
||||
publish: string;
|
||||
functions?: string;
|
||||
environment?: Record<string, string>;
|
||||
};
|
||||
redirects?: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
status?: number;
|
||||
force?: boolean;
|
||||
}>;
|
||||
headers?: Array<{
|
||||
for: string;
|
||||
values: Record<string, string>;
|
||||
}>;
|
||||
functions?: {
|
||||
[key: string]: {
|
||||
included_files?: string[];
|
||||
external_node_modules?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FrameworkConfig {
|
||||
name: string;
|
||||
buildCommand: string;
|
||||
outputDirectory: string;
|
||||
nodeVersion: string;
|
||||
installCommand?: string;
|
||||
envVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
const FRAMEWORK_CONFIGS: Record<string, FrameworkConfig> = {
|
||||
react: {
|
||||
name: 'React',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'build',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
'react-vite': {
|
||||
name: 'React (Vite)',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
vue: {
|
||||
name: 'Vue',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
angular: {
|
||||
name: 'Angular',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
svelte: {
|
||||
name: 'Svelte',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
'svelte-kit': {
|
||||
name: 'SvelteKit',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.svelte-kit',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
next: {
|
||||
name: 'Next.js',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.next',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
envVars: {
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
},
|
||||
nuxt: {
|
||||
name: 'Nuxt',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.output/public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
gatsby: {
|
||||
name: 'Gatsby',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
remix: {
|
||||
name: 'Remix',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
astro: {
|
||||
name: 'Astro',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
static: {
|
||||
name: 'Static Site',
|
||||
buildCommand: '',
|
||||
outputDirectory: '.',
|
||||
nodeVersion: '18',
|
||||
},
|
||||
};
|
||||
|
||||
export function detectFramework(packageJson: any): string {
|
||||
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
||||
|
||||
// Check for specific frameworks
|
||||
if (deps.next) {
|
||||
return 'next';
|
||||
}
|
||||
|
||||
if (deps.nuxt || deps.nuxt3) {
|
||||
return 'nuxt';
|
||||
}
|
||||
|
||||
if (deps.gatsby) {
|
||||
return 'gatsby';
|
||||
}
|
||||
|
||||
if (deps['@remix-run/react']) {
|
||||
return 'remix';
|
||||
}
|
||||
|
||||
if (deps.astro) {
|
||||
return 'astro';
|
||||
}
|
||||
|
||||
if (deps['@angular/core']) {
|
||||
return 'angular';
|
||||
}
|
||||
|
||||
if (deps['@sveltejs/kit']) {
|
||||
return 'svelte-kit';
|
||||
}
|
||||
|
||||
if (deps.svelte) {
|
||||
return 'svelte';
|
||||
}
|
||||
|
||||
if (deps.vue) {
|
||||
return 'vue';
|
||||
}
|
||||
|
||||
if (deps.react) {
|
||||
if (deps.vite) {
|
||||
return 'react-vite';
|
||||
}
|
||||
|
||||
return 'react';
|
||||
}
|
||||
|
||||
return 'static';
|
||||
}
|
||||
|
||||
export function generateNetlifyConfig(framework: string, customConfig?: Partial<NetlifyConfig>): NetlifyConfig {
|
||||
const frameworkConfig = FRAMEWORK_CONFIGS[framework] || FRAMEWORK_CONFIGS.static;
|
||||
|
||||
const config: NetlifyConfig = {
|
||||
build: {
|
||||
command: frameworkConfig.buildCommand,
|
||||
publish: frameworkConfig.outputDirectory,
|
||||
environment: {
|
||||
NODE_VERSION: frameworkConfig.nodeVersion,
|
||||
...frameworkConfig.envVars,
|
||||
...customConfig?.build?.environment,
|
||||
},
|
||||
},
|
||||
redirects: [],
|
||||
headers: [
|
||||
{
|
||||
for: '/*',
|
||||
values: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Add SPA redirect for client-side routing frameworks
|
||||
if (['react', 'react-vite', 'vue', 'angular', 'svelte'].includes(framework)) {
|
||||
config.redirects!.push({
|
||||
from: '/*',
|
||||
to: '/index.html',
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// Add custom headers for static assets
|
||||
config.headers!.push({
|
||||
for: '/assets/*',
|
||||
values: {
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
|
||||
// Merge with custom config
|
||||
if (customConfig) {
|
||||
if (customConfig.redirects) {
|
||||
config.redirects!.push(...customConfig.redirects);
|
||||
}
|
||||
|
||||
if (customConfig.headers) {
|
||||
config.headers!.push(...customConfig.headers);
|
||||
}
|
||||
|
||||
if (customConfig.functions) {
|
||||
config.functions = customConfig.functions;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function generateNetlifyToml(config: NetlifyConfig): string {
|
||||
let toml = '';
|
||||
|
||||
// Build configuration
|
||||
toml += '[build]\n';
|
||||
|
||||
if (config.build.command) {
|
||||
toml += ` command = "${config.build.command}"\n`;
|
||||
}
|
||||
|
||||
toml += ` publish = "${config.build.publish}"\n`;
|
||||
|
||||
if (config.build.functions) {
|
||||
toml += ` functions = "${config.build.functions}"\n`;
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
if (config.build.environment && Object.keys(config.build.environment).length > 0) {
|
||||
toml += '\n[build.environment]\n';
|
||||
|
||||
for (const [key, value] of Object.entries(config.build.environment)) {
|
||||
toml += ` ${key} = "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirects
|
||||
if (config.redirects && config.redirects.length > 0) {
|
||||
for (const redirect of config.redirects) {
|
||||
toml += '\n[[redirects]]\n';
|
||||
toml += ` from = "${redirect.from}"\n`;
|
||||
toml += ` to = "${redirect.to}"\n`;
|
||||
|
||||
if (redirect.status) {
|
||||
toml += ` status = ${redirect.status}\n`;
|
||||
}
|
||||
|
||||
if (redirect.force) {
|
||||
toml += ` force = ${redirect.force}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (config.headers && config.headers.length > 0) {
|
||||
for (const header of config.headers) {
|
||||
toml += '\n[[headers]]\n';
|
||||
toml += ` for = "${header.for}"\n`;
|
||||
|
||||
if (Object.keys(header.values).length > 0) {
|
||||
toml += ' [headers.values]\n';
|
||||
|
||||
for (const [key, value] of Object.entries(header.values)) {
|
||||
toml += ` "${key}" = "${value}"\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Functions configuration
|
||||
if (config.functions) {
|
||||
for (const [funcName, funcConfig] of Object.entries(config.functions)) {
|
||||
toml += `\n[functions."${funcName}"]\n`;
|
||||
|
||||
if (funcConfig.included_files) {
|
||||
toml += ` included_files = ${JSON.stringify(funcConfig.included_files)}\n`;
|
||||
}
|
||||
|
||||
if (funcConfig.external_node_modules) {
|
||||
toml += ` external_node_modules = ${JSON.stringify(funcConfig.external_node_modules)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toml;
|
||||
}
|
||||
|
||||
export function validateDeploymentFiles(files: Record<string, string>): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for index.html
|
||||
const hasIndex = Object.keys(files).some(
|
||||
(path) => path === '/index.html' || path === 'index.html' || path.endsWith('/index.html'),
|
||||
);
|
||||
|
||||
if (!hasIndex) {
|
||||
warnings.push('No index.html file found. Make sure your build output includes an entry point.');
|
||||
}
|
||||
|
||||
// Check file sizes
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const WARN_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const size = new Blob([content]).size;
|
||||
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
errors.push(`File ${path} exceeds maximum size of 100MB`);
|
||||
} else if (size > WARN_FILE_SIZE) {
|
||||
warnings.push(`File ${path} is large (${Math.round(size / 1024 / 1024)}MB)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check total deployment size
|
||||
const totalSize = Object.values(files).reduce((sum, content) => sum + new Blob([content]).size, 0);
|
||||
|
||||
const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
errors.push(`Total deployment size exceeds 500MB limit`);
|
||||
}
|
||||
|
||||
// Check for common issues
|
||||
if (Object.keys(files).some((path) => path.includes('node_modules'))) {
|
||||
warnings.push('Deployment includes node_modules - these should typically be excluded');
|
||||
}
|
||||
|
||||
if (Object.keys(files).some((path) => path.includes('.env'))) {
|
||||
errors.push('Deployment includes .env file - remove sensitive configuration files');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -20,18 +20,6 @@ export default class AmazonBedrockProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
label: 'Claude Sonnet 4 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-opus-4-1-20250805-v1:0',
|
||||
label: 'Claude Opus 4.1 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
label: 'Claude 3.5 Sonnet v2 (Bedrock)',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import type { LanguageModelV1 } from 'ai';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
|
||||
export class AnthropicProvider extends BaseProvider {
|
||||
export default class AnthropicProvider extends BaseProvider {
|
||||
name = 'Anthropic';
|
||||
getApiKeyLink = 'https://console.anthropic.com/settings/keys';
|
||||
|
||||
@@ -13,50 +13,6 @@ export class AnthropicProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
/*
|
||||
* Claude Opus 4.1: Most powerful model for coding and reasoning
|
||||
* Released August 5, 2025
|
||||
*/
|
||||
{
|
||||
name: 'claude-opus-4-1-20250805',
|
||||
label: 'Claude Opus 4.1',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-opus-4-1-20250805-smartai',
|
||||
label: 'Claude Opus 4.1 (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
/*
|
||||
* Claude Sonnet 4: Hybrid instant/extended response model
|
||||
* Released May 14, 2025
|
||||
*/
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514-smartai',
|
||||
label: 'Claude Sonnet 4 (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
/*
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* Claude 3.5 Sonnet: 200k context, excellent for complex reasoning and coding
|
||||
@@ -66,17 +22,7 @@ export class AnthropicProvider extends BaseProvider {
|
||||
label: 'Claude 3.5 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-20241022-smartai',
|
||||
label: 'Claude 3.5 Sonnet (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
maxCompletionTokens: 128000,
|
||||
},
|
||||
|
||||
// Claude 3 Haiku: 200k context, fastest and most cost-effective
|
||||
@@ -85,17 +31,16 @@ export class AnthropicProvider extends BaseProvider {
|
||||
label: 'Claude 3 Haiku',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
maxCompletionTokens: 128000,
|
||||
},
|
||||
|
||||
// Claude Opus 4: 200k context, 32k output limit (latest flagship model)
|
||||
{
|
||||
name: 'claude-3-haiku-20240307-smartai',
|
||||
label: 'Claude 3 Haiku (SmartAI)',
|
||||
name: 'claude-opus-4-20250514',
|
||||
label: 'Claude 4 Opus',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
maxCompletionTokens: 32000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -119,8 +64,7 @@ export class AnthropicProvider extends BaseProvider {
|
||||
const response = await fetch(`https://api.anthropic.com/v1/models`, {
|
||||
headers: {
|
||||
'x-api-key': `${apiKey}`,
|
||||
['anthropic-version']: '2023-06-01',
|
||||
['Content-Type']: 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,21 +90,15 @@ export class AnthropicProvider extends BaseProvider {
|
||||
contextWindow = 200000; // Claude 3 Sonnet has 200k context
|
||||
}
|
||||
|
||||
// Determine max completion tokens based on model
|
||||
let maxCompletionTokens = 4096; // default fallback
|
||||
// Determine completion token limits based on specific model
|
||||
let maxCompletionTokens = 128000; // default for older Claude 3 models
|
||||
|
||||
if (m.id?.includes('claude-sonnet-4') || m.id?.includes('claude-opus-4')) {
|
||||
maxCompletionTokens = 64000;
|
||||
} else if (m.id?.includes('claude-3-7-sonnet')) {
|
||||
maxCompletionTokens = 64000;
|
||||
} else if (m.id?.includes('claude-3-5-sonnet')) {
|
||||
maxCompletionTokens = 8192;
|
||||
} else if (m.id?.includes('claude-3-haiku')) {
|
||||
maxCompletionTokens = 4096;
|
||||
} else if (m.id?.includes('claude-3-opus')) {
|
||||
maxCompletionTokens = 4096;
|
||||
} else if (m.id?.includes('claude-3-sonnet')) {
|
||||
maxCompletionTokens = 4096;
|
||||
if (m.id?.includes('claude-opus-4')) {
|
||||
maxCompletionTokens = 32000; // Claude 4 Opus: 32K output limit
|
||||
} else if (m.id?.includes('claude-sonnet-4')) {
|
||||
maxCompletionTokens = 64000; // Claude 4 Sonnet: 64K output limit
|
||||
} else if (m.id?.includes('claude-4')) {
|
||||
maxCompletionTokens = 32000; // Other Claude 4 models: conservative 32K limit
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -169,7 +107,6 @@ export class AnthropicProvider extends BaseProvider {
|
||||
provider: this.name,
|
||||
maxTokenAllowed: contextWindow,
|
||||
maxCompletionTokens,
|
||||
supportsSmartAI: true, // All Anthropic models support SmartAI
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -180,27 +117,19 @@ export class AnthropicProvider extends BaseProvider {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModelV1 = (options) => {
|
||||
const { model, serverEnv, apiKeys, providerSettings } = options;
|
||||
const { apiKey, baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
const { apiKeys, providerSettings, serverEnv, model } = options;
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
providerSettings,
|
||||
serverEnv: serverEnv as any,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing API key for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || 'https://api.anthropic.com/v1',
|
||||
headers: { 'anthropic-beta': 'output-128k-2025-02-19' },
|
||||
});
|
||||
|
||||
// Handle SmartAI variant by using the base model name
|
||||
const actualModel = model.replace('-smartai', '');
|
||||
|
||||
return anthropic(actualModel);
|
||||
return anthropic(model);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,18 +31,6 @@ export default class OpenRouterProvider extends BaseProvider {
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* Claude 3.5 Sonnet via OpenRouter: 200k context
|
||||
*/
|
||||
{
|
||||
name: 'anthropic/claude-sonnet-4-20250514',
|
||||
label: 'Anthropic: Claude Sonnet 4 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-opus-4-1-20250805',
|
||||
label: 'Anthropic: Claude Opus 4.1 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-3.5-sonnet',
|
||||
label: 'Claude 3.5 Sonnet',
|
||||
|
||||
@@ -17,23 +17,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* GPT-4o: 128k context, 4k standard output (64k with long output mode)
|
||||
*/
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-4o-smartai',
|
||||
label: 'GPT-4o (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 4096 },
|
||||
|
||||
// GPT-4o Mini: 128k context, cost-effective alternative
|
||||
{
|
||||
@@ -42,16 +26,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-4o-mini-smartai',
|
||||
label: 'GPT-4o Mini (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// GPT-3.5-turbo: 16k context, fast and cost-effective
|
||||
@@ -61,16 +35,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 16000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-3.5-turbo-smartai',
|
||||
label: 'GPT-3.5 Turbo (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 16000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// o1-preview: 128k context, 32k output limit (reasoning model)
|
||||
@@ -80,36 +44,10 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 32000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'o1-preview-smartai',
|
||||
label: 'o1-preview (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 32000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// o1-mini: 128k context, 65k output limit (reasoning model)
|
||||
{
|
||||
name: 'o1-mini',
|
||||
label: 'o1-mini',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 65000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'o1-mini-smartai',
|
||||
label: 'o1-mini (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 65000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
{ name: 'o1-mini', label: 'o1-mini', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 65000 },
|
||||
];
|
||||
|
||||
async getDynamicModels(
|
||||
@@ -187,7 +125,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: this.name,
|
||||
maxTokenAllowed: Math.min(contextWindow, 128000), // Cap at 128k for safety
|
||||
maxCompletionTokens,
|
||||
supportsSmartAI: true, // All OpenAI models support SmartAI
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -216,9 +153,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Handle SmartAI variant by using the base model name
|
||||
const actualModel = model.replace('-smartai', '');
|
||||
|
||||
return openai(actualModel);
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnthropicProvider } from './providers/anthropic';
|
||||
import AnthropicProvider from './providers/anthropic';
|
||||
import CohereProvider from './providers/cohere';
|
||||
import DeepseekProvider from './providers/deepseek';
|
||||
import GoogleProvider from './providers/google';
|
||||
|
||||
@@ -11,12 +11,6 @@ export interface ModelInfo {
|
||||
|
||||
/** Maximum completion/output tokens - how many tokens the model can generate. If not specified, falls back to provider defaults */
|
||||
maxCompletionTokens?: number;
|
||||
|
||||
/** Indicates if this model supports SmartAI enhanced feedback */
|
||||
supportsSmartAI?: boolean;
|
||||
|
||||
/** Indicates if SmartAI is currently enabled for this model variant */
|
||||
isSmartAIEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import type { ChatHistoryItem } from './useChatHistory';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
|
||||
export interface IUserChatMetadata {
|
||||
userId: string;
|
||||
gitUrl?: string;
|
||||
gitBranch?: string;
|
||||
netlifySiteId?: string;
|
||||
}
|
||||
|
||||
const logger = createScopedLogger('UserChatHistory');
|
||||
|
||||
/**
|
||||
* Open user-specific database
|
||||
*/
|
||||
export async function openUserDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
console.error('indexedDB is not available in this environment.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
console.error('No authenticated user found.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use user-specific database name
|
||||
const dbName = `boltHistory_${authState.user.id}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
|
||||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains('chats')) {
|
||||
const store = db.createObjectStore('chats', { keyPath: 'id' });
|
||||
store.createIndex('id', 'id', { unique: true });
|
||||
store.createIndex('urlId', 'urlId', { unique: true });
|
||||
store.createIndex('userId', 'userId', { unique: false });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('snapshots')) {
|
||||
db.createObjectStore('snapshots', { keyPath: 'chatId' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('settings')) {
|
||||
db.createObjectStore('settings', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('workspaces')) {
|
||||
const workspaceStore = db.createObjectStore('workspaces', { keyPath: 'id' });
|
||||
workspaceStore.createIndex('name', 'name', { unique: false });
|
||||
workspaceStore.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event: Event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = (event: Event) => {
|
||||
resolve(undefined);
|
||||
logger.error((event.target as IDBOpenDBRequest).error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chats for current user
|
||||
*/
|
||||
export async function getUserChats(db: IDBDatabase): Promise<ChatHistoryItem[]> {
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('chats', 'readonly');
|
||||
const store = transaction.objectStore('chats');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
// Filter by userId and sort by timestamp
|
||||
const chats = (request.result as ChatHistoryItem[]).sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
|
||||
resolve(chats);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user-specific settings
|
||||
*/
|
||||
export async function saveUserSetting(db: IDBDatabase, key: string, value: any): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('settings', 'readwrite');
|
||||
const store = transaction.objectStore('settings');
|
||||
|
||||
const request = store.put({ key, value, updatedAt: new Date().toISOString() });
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user-specific settings
|
||||
*/
|
||||
export async function loadUserSetting(db: IDBDatabase, key: string): Promise<any | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('settings', 'readonly');
|
||||
const store = transaction.objectStore('settings');
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
resolve(result ? result.value : null);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace for the user
|
||||
*/
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
lastAccessed?: string;
|
||||
files?: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function createWorkspace(db: IDBDatabase, workspace: Omit<Workspace, 'id'>): Promise<string> {
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
|
||||
const workspaceId = `workspace_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readwrite');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
|
||||
const fullWorkspace: Workspace = {
|
||||
id: workspaceId,
|
||||
...workspace,
|
||||
};
|
||||
|
||||
const request = store.add(fullWorkspace);
|
||||
|
||||
request.onsuccess = () => resolve(workspaceId);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user workspaces
|
||||
*/
|
||||
export async function getUserWorkspaces(db: IDBDatabase): Promise<Workspace[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readonly');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const workspaces = (request.result as Workspace[]).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
resolve(workspaces);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workspace
|
||||
*/
|
||||
export async function deleteWorkspace(db: IDBDatabase, workspaceId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readwrite');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
const request = store.delete(workspaceId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*/
|
||||
export async function getUserStats(db: IDBDatabase): Promise<{
|
||||
totalChats: number;
|
||||
totalWorkspaces: number;
|
||||
lastActivity?: string;
|
||||
storageUsed?: number;
|
||||
}> {
|
||||
try {
|
||||
const [chats, workspaces] = await Promise.all([getUserChats(db), getUserWorkspaces(db)]);
|
||||
|
||||
// Calculate last activity
|
||||
let lastActivity: string | undefined;
|
||||
|
||||
const allTimestamps = [
|
||||
...chats.map((c) => c.timestamp),
|
||||
...workspaces.map((w) => w.lastAccessed || w.createdAt),
|
||||
].filter(Boolean);
|
||||
|
||||
if (allTimestamps.length > 0) {
|
||||
lastActivity = allTimestamps.sort().reverse()[0];
|
||||
}
|
||||
|
||||
return {
|
||||
totalChats: chats.length,
|
||||
totalWorkspaces: workspaces.length,
|
||||
lastActivity,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user stats:', error);
|
||||
return {
|
||||
totalChats: 0,
|
||||
totalWorkspaces: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { atom, map } from 'nanostores';
|
||||
import type { UserProfile } from '~/lib/utils/fileUserStorage';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: Omit<UserProfile, 'passwordHash'> | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// Authentication state store
|
||||
export const authStore = map<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// Remember me preference
|
||||
export const rememberMeStore = atom<boolean>(false);
|
||||
|
||||
// Session timeout tracking
|
||||
let sessionTimeout: NodeJS.Timeout | null = null;
|
||||
const SESSION_TIMEOUT = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Initialize auth from stored token
|
||||
*/
|
||||
export async function initializeAuth(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
authStore.setKey('loading', true);
|
||||
|
||||
try {
|
||||
const token = Cookies.get('auth_token');
|
||||
|
||||
if (token) {
|
||||
// Verify token with backend
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { user: Omit<UserProfile, 'passwordHash'> };
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user,
|
||||
token,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
} else {
|
||||
// Token is invalid, clear it
|
||||
clearAuth();
|
||||
}
|
||||
} else {
|
||||
authStore.setKey('loading', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
authStore.setKey('loading', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication state
|
||||
*/
|
||||
export function setAuthState(state: AuthState): void {
|
||||
authStore.set(state);
|
||||
|
||||
if (state.token) {
|
||||
// Store token in cookie
|
||||
const cookieOptions = rememberMeStore.get()
|
||||
? { expires: 7 } // 7 days
|
||||
: undefined; // Session cookie
|
||||
|
||||
Cookies.set('auth_token', state.token, cookieOptions);
|
||||
|
||||
// Store user preferences in localStorage
|
||||
if (state.user) {
|
||||
localStorage.setItem(`bolt_user_${state.user.id}`, JSON.stringify(state.user.preferences || {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
rememberMe: boolean = false,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
user?: Omit<UserProfile, 'passwordHash'>;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
rememberMeStore.set(rememberMe);
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user || null,
|
||||
token: data.token || null,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signup new user
|
||||
*/
|
||||
export async function signup(
|
||||
username: string,
|
||||
password: string,
|
||||
firstName: string,
|
||||
avatar?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password, firstName, avatar }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
user?: Omit<UserProfile, 'passwordHash'>;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user || null,
|
||||
token: data.token || null,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Signup failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
const state = authStore.get();
|
||||
|
||||
if (state.token) {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${state.token}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state
|
||||
*/
|
||||
function clearAuth(): void {
|
||||
authStore.set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
Cookies.remove('auth_token');
|
||||
stopSessionTimer();
|
||||
|
||||
// Clear user-specific localStorage
|
||||
const currentUser = authStore.get().user;
|
||||
|
||||
if (currentUser?.id) {
|
||||
// Keep preferences but clear sensitive data
|
||||
const prefs = localStorage.getItem(`bolt_user_${currentUser.id}`);
|
||||
|
||||
if (prefs) {
|
||||
try {
|
||||
const parsed = JSON.parse(prefs);
|
||||
delete parsed.deploySettings;
|
||||
delete parsed.githubSettings;
|
||||
localStorage.setItem(`bolt_user_${currentUser.id}`, JSON.stringify(parsed));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start session timer
|
||||
*/
|
||||
function startSessionTimer(): void {
|
||||
stopSessionTimer();
|
||||
|
||||
if (!rememberMeStore.get()) {
|
||||
sessionTimeout = setTimeout(() => {
|
||||
logout();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
}, SESSION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop session timer
|
||||
*/
|
||||
function stopSessionTimer(): void {
|
||||
if (sessionTimeout) {
|
||||
clearTimeout(sessionTimeout);
|
||||
sessionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export async function updateProfile(
|
||||
updates: Partial<Omit<UserProfile, 'passwordHash' | 'id' | 'username'>>,
|
||||
): Promise<boolean> {
|
||||
const state = authStore.get();
|
||||
|
||||
if (!state.token || !state.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${state.token}`,
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedUser = (await response.json()) as Omit<UserProfile, 'passwordHash'>;
|
||||
authStore.setKey('user', updatedUser);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize auth on load
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeAuth();
|
||||
}
|
||||
@@ -223,13 +223,10 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
async saveFile(filePath: string) {
|
||||
console.log(`[WorkbenchStore] saveFile called for: ${filePath}`);
|
||||
|
||||
const documents = this.#editorStore.documents.get();
|
||||
const document = documents[filePath];
|
||||
|
||||
if (document === undefined) {
|
||||
console.warn(`[WorkbenchStore] No document found for: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,39 +236,21 @@ export class WorkbenchStore {
|
||||
* This is a more complex feature that would be implemented in a future update
|
||||
*/
|
||||
|
||||
try {
|
||||
console.log(`[WorkbenchStore] Saving to file system: ${filePath}`);
|
||||
await this.#filesStore.saveFile(filePath, document.value);
|
||||
console.log(`[WorkbenchStore] File saved successfully: ${filePath}`);
|
||||
|
||||
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
||||
const wasUnsaved = newUnsavedFiles.has(filePath);
|
||||
newUnsavedFiles.delete(filePath);
|
||||
|
||||
console.log(`[WorkbenchStore] Updating unsaved files:`, {
|
||||
filePath,
|
||||
wasUnsaved,
|
||||
previousCount: this.unsavedFiles.get().size,
|
||||
newCount: newUnsavedFiles.size,
|
||||
remainingFiles: Array.from(newUnsavedFiles),
|
||||
});
|
||||
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
} catch (error) {
|
||||
console.error(`[WorkbenchStore] Failed to save file ${filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentDocument() {
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
if (currentDocument === undefined) {
|
||||
console.warn('[WorkbenchStore] No current document to save');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WorkbenchStore] Saving current document: ${currentDocument.filePath}`);
|
||||
await this.saveFile(currentDocument.filePath);
|
||||
}
|
||||
|
||||
@@ -293,14 +272,9 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
async saveAllFiles() {
|
||||
const filesToSave = Array.from(this.unsavedFiles.get());
|
||||
console.log(`[WorkbenchStore] saveAllFiles called for ${filesToSave.length} files:`, filesToSave);
|
||||
|
||||
for (const filePath of filesToSave) {
|
||||
for (const filePath of this.unsavedFiles.get()) {
|
||||
await this.saveFile(filePath);
|
||||
}
|
||||
|
||||
console.log('[WorkbenchStore] saveAllFiles complete. Remaining unsaved:', Array.from(this.unsavedFiles.get()));
|
||||
}
|
||||
|
||||
getFileModifcations() {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Use a secure secret key (in production, this should be an environment variable)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'bolt-multi-user-secret-key-2024-secure';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token
|
||||
*/
|
||||
export function generateToken(payload: Omit<JWTPayload, 'exp'>): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
...payload,
|
||||
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
JWT_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
*/
|
||||
export function verifyToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure user ID
|
||||
*/
|
||||
export function generateUserId(): string {
|
||||
return `user_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { generateUserId, hashPassword } from './crypto';
|
||||
|
||||
const USERS_DIR = path.join(process.cwd(), '.users');
|
||||
const USERS_INDEX_FILE = path.join(USERS_DIR, 'users.json');
|
||||
const USER_DATA_DIR = path.join(USERS_DIR, 'data');
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
passwordHash: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'light' | 'dark';
|
||||
deploySettings: {
|
||||
netlify?: any;
|
||||
vercel?: any;
|
||||
};
|
||||
githubSettings?: any;
|
||||
workspaceConfig: any;
|
||||
}
|
||||
|
||||
export interface SecurityLog {
|
||||
timestamp: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
action: 'login' | 'logout' | 'signup' | 'delete' | 'error' | 'failed_login';
|
||||
details: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the user storage system
|
||||
*/
|
||||
export async function initializeUserStorage(): Promise<void> {
|
||||
try {
|
||||
// Create directories if they don't exist
|
||||
await fs.mkdir(USERS_DIR, { recursive: true });
|
||||
await fs.mkdir(USER_DATA_DIR, { recursive: true });
|
||||
|
||||
// Create users index if it doesn't exist
|
||||
try {
|
||||
await fs.access(USERS_INDEX_FILE);
|
||||
} catch {
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users: [] }, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize user storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (without passwords)
|
||||
*/
|
||||
export async function getAllUsers(): Promise<Omit<UserProfile, 'passwordHash'>[]> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.map(({ passwordHash, ...user }) => user);
|
||||
} catch (error) {
|
||||
console.error('Failed to get users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by username
|
||||
*/
|
||||
export async function getUserByUsername(username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.find((u) => u.username === username) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by ID
|
||||
*/
|
||||
export async function getUserById(id: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.find((u) => u.id === id) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
firstName: string,
|
||||
avatar?: string,
|
||||
): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await getUserByUsername(username);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUser: UserProfile = {
|
||||
id: generateUserId(),
|
||||
username,
|
||||
firstName,
|
||||
passwordHash: await hashPassword(password),
|
||||
avatar,
|
||||
createdAt: new Date().toISOString(),
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
deploySettings: {},
|
||||
workspaceConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Load existing users
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
// Add new user
|
||||
users.push(newUser);
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
// Create user data directory
|
||||
const userDataDir = path.join(USER_DATA_DIR, newUser.id);
|
||||
await fs.mkdir(userDataDir, { recursive: true });
|
||||
|
||||
// Log the signup
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: newUser.id,
|
||||
username: newUser.username,
|
||||
action: 'signup',
|
||||
details: `User ${newUser.username} created successfully`,
|
||||
});
|
||||
|
||||
return newUser;
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Failed to create user ${username}: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export async function updateUser(userId: string, updates: Partial<UserProfile>): Promise<boolean> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user (excluding certain fields)
|
||||
const { id, username, passwordHash, ...safeUpdates } = updates;
|
||||
users[userIndex] = {
|
||||
...users[userIndex],
|
||||
...safeUpdates,
|
||||
};
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login time
|
||||
*/
|
||||
export async function updateLastLogin(userId: string): Promise<void> {
|
||||
await updateUser(userId, { lastLogin: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
export async function deleteUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deletedUser = users[userIndex];
|
||||
|
||||
// Remove user from list
|
||||
users.splice(userIndex, 1);
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
// Delete user data directory
|
||||
const userDataDir = path.join(USER_DATA_DIR, userId);
|
||||
|
||||
try {
|
||||
await fs.rm(userDataDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete user data directory: ${error}`);
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId,
|
||||
username: deletedUser.username,
|
||||
action: 'delete',
|
||||
details: `User ${deletedUser.username} deleted`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user-specific data
|
||||
*/
|
||||
export async function saveUserData(userId: string, key: string, data: any): Promise<void> {
|
||||
try {
|
||||
const userDataDir = path.join(USER_DATA_DIR, userId);
|
||||
await fs.mkdir(userDataDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(userDataDir, `${key}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`Failed to save user data for ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user-specific data
|
||||
*/
|
||||
export async function loadUserData(userId: string, key: string): Promise<any | null> {
|
||||
try {
|
||||
const filePath = path.join(USER_DATA_DIR, userId, `${key}.json`);
|
||||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security events
|
||||
*/
|
||||
export async function logSecurityEvent(event: SecurityLog): Promise<void> {
|
||||
try {
|
||||
const logFile = path.join(USERS_DIR, 'security.log');
|
||||
const logEntry = `${JSON.stringify(event)}\n`;
|
||||
|
||||
await fs.appendFile(logFile, logEntry);
|
||||
} catch (error) {
|
||||
console.error('Failed to log security event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security logs
|
||||
*/
|
||||
export async function getSecurityLogs(limit: number = 100): Promise<SecurityLog[]> {
|
||||
try {
|
||||
const logFile = path.join(USERS_DIR, 'security.log');
|
||||
const data = await fs.readFile(logFile, 'utf-8');
|
||||
|
||||
const logs = data
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as SecurityLog;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as SecurityLog[];
|
||||
|
||||
return logs.slice(-limit).reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,173 +1,28 @@
|
||||
import { json, type MetaFunction } from '@remix-run/cloudflare';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { providersStore } from '~/lib/stores/settings';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: 'Bolt.gives' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Build web applications with AI assistance - Enhanced fork with advanced features',
|
||||
},
|
||||
];
|
||||
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
||||
};
|
||||
|
||||
export const loader = ({ context }: { context: any }) => {
|
||||
// Check which local providers are configured
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json({ configuredProviders });
|
||||
};
|
||||
export const loader = () => json({});
|
||||
|
||||
/**
|
||||
* Landing page component for Bolt.gives
|
||||
* Enhanced fork with multi-user authentication, advanced features, and provider auto-detection
|
||||
* Landing page component for Bolt
|
||||
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
|
||||
* Do not add settings button/panel to this landing page as it was intentionally removed
|
||||
* to keep the UI clean and consistent with the design system.
|
||||
*/
|
||||
export default function Index() {
|
||||
const data = useLoaderData<{ configuredProviders: string[] }>();
|
||||
const [showMultiUserBanner, setShowMultiUserBanner] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Enable configured providers if they haven't been manually configured yet
|
||||
if (data?.configuredProviders && data.configuredProviders.length > 0) {
|
||||
const savedSettings = localStorage.getItem('provider_settings');
|
||||
|
||||
if (!savedSettings) {
|
||||
// No saved settings, so enable the configured providers
|
||||
const currentProviders = providersStore.get();
|
||||
data.configuredProviders.forEach((providerName) => {
|
||||
if (currentProviders[providerName]) {
|
||||
providersStore.setKey(providerName, {
|
||||
...currentProviders[providerName],
|
||||
settings: {
|
||||
...currentProviders[providerName].settings,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save to localStorage so this only happens once
|
||||
localStorage.setItem('provider_settings', JSON.stringify(providersStore.get()));
|
||||
}
|
||||
}
|
||||
}, [data?.configuredProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const authState = authStore.get();
|
||||
|
||||
// Show banner only if not authenticated and hasn't been dismissed
|
||||
const bannerDismissed = localStorage.getItem('multiUserBannerDismissed');
|
||||
|
||||
if (!authState.isAuthenticated && !bannerDismissed) {
|
||||
setTimeout(() => setShowMultiUserBanner(true), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivateMultiUser = () => {
|
||||
navigate('/auth');
|
||||
};
|
||||
|
||||
const handleDismissBanner = () => {
|
||||
setShowMultiUserBanner(false);
|
||||
localStorage.setItem('multiUserBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
|
||||
|
||||
{/* Optional Multi-User Activation Banner */}
|
||||
<AnimatePresence>
|
||||
{showMultiUserBanner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed bottom-6 right-6 max-w-sm z-50"
|
||||
>
|
||||
<div className="bg-bolt-elements-background-depth-2 backdrop-blur-xl rounded-xl border border-bolt-elements-borderColor shadow-2xl p-4">
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="absolute top-2 right-2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-bolt-elements-textPrimary mb-1">
|
||||
Unlock Multi-User Features
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mb-3">
|
||||
Save your projects, personalized settings, and collaborate with workspace isolation.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleActivateMultiUser}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Activate Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover transition-all"
|
||||
>
|
||||
Continue as Guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { ProtectedRoute } from '~/components/auth/ProtectedRoute';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { users: User[] };
|
||||
setUsers(data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${selectedUser.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(users.filter((u) => u.id !== selectedUser.id));
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.firstName.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-bolt-elements-background-depth-1">
|
||||
{/* Header */}
|
||||
<header className="border-b border-bolt-elements-borderColor bg-bolt-elements-background-depth-2">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 rounded-lg hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
>
|
||||
<span className="i-ph:arrow-left text-xl text-bolt-elements-textPrimary" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-bolt-elements-textPrimary">User Management</h1>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Manage system users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={classNames(
|
||||
'w-64 px-4 py-2 pl-10 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500',
|
||||
)}
|
||||
/>
|
||||
<span className="absolute left-3 top-2.5 i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/auth')}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'transition-colors',
|
||||
'flex items-center gap-2',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:plus text-lg" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* User Stats */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Total Users</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">{users.length}</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Active Today</p>
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
if (!u.lastLogin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastLogin = new Date(u.lastLogin);
|
||||
const today = new Date();
|
||||
|
||||
return lastLogin.toDateString() === today.toDateString();
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">New This Week</p>
|
||||
<p className="text-2xl font-bold text-blue-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
const created = new Date(u.createdAt);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
return created > weekAgo;
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Storage Used</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">0 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<span className="i-ph:users text-4xl text-bolt-elements-textTertiary mb-4" />
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
{searchQuery ? 'No users found matching your search' : 'No users yet'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{filteredUsers.map((user, index) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 rounded-lg p-6',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'hover:shadow-lg transition-all',
|
||||
user.id === authState.user?.id ? 'ring-2 ring-accent-500' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-3 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.firstName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName}
|
||||
{user.id === authState.user?.id && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-accent-500/20 text-accent-500">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
title="Edit user"
|
||||
>
|
||||
<span className="i-ph:pencil text-bolt-elements-textSecondary" />
|
||||
</button>
|
||||
{user.id !== authState.user?.id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<span className="i-ph:trash text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:calendar-blank" />
|
||||
<span>Joined {new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{user.lastLogin && (
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:clock" />
|
||||
<span>Last active {new Date(user.lastLogin).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{showDeleteModal && selectedUser && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => !deleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.95 }}
|
||||
className="bg-bolt-elements-background-depth-2 rounded-lg p-6 max-w-md w-full border border-bolt-elements-borderColor"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-bolt-elements-textPrimary mb-2">Delete User</h2>
|
||||
<p className="text-bolt-elements-textSecondary mb-6">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-medium text-bolt-elements-textPrimary">@{selectedUser.username}</span>? This
|
||||
action cannot be undone and will permanently remove all user data.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:trash" />
|
||||
Delete User
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { getUserByUsername, updateLastLogin, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { verifyPassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as { username?: string; password?: string };
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt for non-existent user: ${username}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt with incorrect password`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
await updateLastLogin(user.id);
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful login
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'login',
|
||||
details: 'Successful login',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Login error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (payload) {
|
||||
// Log logout event
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
action: 'logout',
|
||||
details: 'User logged out',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { createUser, getUserByUsername, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { validatePassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
const { username, password, firstName, avatar } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password || !firstName) {
|
||||
return json({ error: 'Username, password, and first name are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
return json(
|
||||
{
|
||||
error: 'Username must be 3-20 characters and contain only letters, numbers, and underscores',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return json({ error: passwordValidation.errors.join('. ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await getUserByUsername(username);
|
||||
|
||||
if (existingUser) {
|
||||
return json({ error: 'Username already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const user = await createUser(username, password, firstName, avatar);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'Failed to create user' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful signup
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'signup',
|
||||
details: 'New user registration',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Signup error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getUserById } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'No token provided' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserById(payload.userId);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { createSummary } from '~/lib/.server/llm/create-summary';
|
||||
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
import { MCPService } from '~/lib/services/mcpService';
|
||||
import { StreamRecoveryManager } from '~/lib/.server/llm/stream-recovery';
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
@@ -75,22 +74,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const encoder: TextEncoder = new TextEncoder();
|
||||
let progressCounter: number = 1;
|
||||
|
||||
// Initialize stream recovery manager
|
||||
const recovery = new StreamRecoveryManager({
|
||||
maxRetries: 3,
|
||||
retryDelay: 2000,
|
||||
timeout: 45000, // 45 seconds timeout
|
||||
onTimeout: () => {
|
||||
logger.warn('Stream timeout detected - attempting recovery');
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
logger.info(`Stream recovery attempt ${attempt}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Stream error in recovery:', error);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const mcpService = MCPService.getInstance();
|
||||
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
|
||||
@@ -330,29 +313,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
recovery.startMonitoring();
|
||||
|
||||
let lastActivityTime = Date.now();
|
||||
const activityCheckInterval = 5000; // Check every 5 seconds
|
||||
|
||||
// Set up activity monitoring
|
||||
const activityChecker = setInterval(() => {
|
||||
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
||||
|
||||
if (timeSinceLastActivity > 30000) {
|
||||
logger.warn(`No stream activity for ${timeSinceLastActivity}ms`);
|
||||
|
||||
// Attempt to recover if stream appears stuck
|
||||
recovery.attemptRecovery();
|
||||
}
|
||||
}, activityCheckInterval);
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
// Record activity
|
||||
lastActivityTime = Date.now();
|
||||
recovery.recordActivity();
|
||||
|
||||
if (part.type === 'error') {
|
||||
const error: any = part.error;
|
||||
logger.error('Streaming error:', error);
|
||||
@@ -364,43 +325,16 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
logger.error('Token-related error detected - possible token limit exceeded');
|
||||
}
|
||||
|
||||
// Attempt recovery for certain errors
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (!canRecover) {
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
} catch (streamError) {
|
||||
logger.error('Fatal stream error:', streamError);
|
||||
recovery.stop();
|
||||
throw streamError;
|
||||
}
|
||||
})();
|
||||
result.mergeIntoDataStream(dataStream);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Stop recovery manager on error
|
||||
recovery.stop();
|
||||
|
||||
// Provide more specific error messages for common issues
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
|
||||
// Log detailed error for debugging
|
||||
logger.error('Chat API error:', {
|
||||
message: errorMessage,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('model') && errorMessage.includes('not found')) {
|
||||
return 'Custom error: Invalid model selected. Please check that the model name is correct and available.';
|
||||
}
|
||||
@@ -426,11 +360,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
|
||||
return 'Custom error: Network error or timeout. The connection was interrupted. Please try again or switch to a different AI model.';
|
||||
}
|
||||
|
||||
if (errorMessage.includes('stream') || errorMessage.includes('hang')) {
|
||||
return 'Custom error: The conversation stream was interrupted. Please refresh the page and try again.';
|
||||
return 'Custom error: Network error. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
return `Custom error: ${errorMessage}`;
|
||||
@@ -473,32 +403,17 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Set up cleanup for recovery manager
|
||||
const cleanupStream = dataStream.pipeThrough(
|
||||
new TransformStream({
|
||||
flush() {
|
||||
recovery.stop();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return new Response(cleanupStream, {
|
||||
return new Response(dataStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
Connection: 'keep-alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Text-Encoding': 'chunked',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Fatal error in chat API:', error);
|
||||
|
||||
// Ensure recovery manager is stopped on error
|
||||
if (typeof recovery !== 'undefined') {
|
||||
recovery.stop();
|
||||
}
|
||||
logger.error(error);
|
||||
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface ModelsResponse {
|
||||
modelList: ModelInfo[];
|
||||
providers: ProviderInfo[];
|
||||
defaultProvider: ProviderInfo;
|
||||
configuredProviders?: string[];
|
||||
}
|
||||
|
||||
let cachedProviders: ProviderInfo[] | null = null;
|
||||
@@ -83,28 +82,9 @@ export async function loader({
|
||||
});
|
||||
}
|
||||
|
||||
// Check which local providers are configured in environment
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json<ModelsResponse>({
|
||||
modelList,
|
||||
providers,
|
||||
defaultProvider,
|
||||
configuredProviders,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Netlify Quick Deploy API Endpoint
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This endpoint handles quick deployments to Netlify without requiring authentication,
|
||||
* using Netlify's drop API for instant deployment.
|
||||
*/
|
||||
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface QuickDeployRequestBody {
|
||||
files: Record<string, string>;
|
||||
chatId: string;
|
||||
framework?: string;
|
||||
}
|
||||
|
||||
// Use environment variable or fallback to public token for quick deploys
|
||||
const NETLIFY_QUICK_DEPLOY_TOKEN = process.env.NETLIFY_QUICK_DEPLOY_TOKEN || '';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { files, chatId, framework } = (await request.json()) as QuickDeployRequestBody;
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
return json({ error: 'No files to deploy' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate a unique site name
|
||||
const siteName = `bolt-quick-${chatId.substring(0, 8)}-${Date.now()}`;
|
||||
|
||||
// Prepare files for Netlify Drop API
|
||||
const deployFiles: Record<string, string> = {};
|
||||
|
||||
// Add index.html if it doesn't exist (for static sites)
|
||||
if (!files['/index.html'] && !files['index.html']) {
|
||||
// Check if there's any HTML file
|
||||
const htmlFile = Object.keys(files).find((f) => f.endsWith('.html'));
|
||||
|
||||
if (!htmlFile) {
|
||||
// Create a basic index.html
|
||||
deployFiles['/index.html'] = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${framework || 'Bolt'} App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Check if there's a main.js or app.js
|
||||
const scripts = ${JSON.stringify(Object.keys(files).filter((f) => f.endsWith('.js')))};
|
||||
if (scripts.length > 0) {
|
||||
const script = document.createElement('script');
|
||||
script.src = scripts[0];
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Process and normalize file paths
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
||||
deployFiles[normalizedPath] = content;
|
||||
}
|
||||
|
||||
// Use Netlify's API to create a new site and deploy
|
||||
let siteId: string | undefined;
|
||||
let deployUrl: string | undefined;
|
||||
|
||||
if (NETLIFY_QUICK_DEPLOY_TOKEN) {
|
||||
// If we have a token, use the authenticated API
|
||||
try {
|
||||
// Create a new site
|
||||
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: siteName,
|
||||
custom_domain: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createSiteResponse.ok) {
|
||||
const site = (await createSiteResponse.json()) as any;
|
||||
siteId = site.id;
|
||||
|
||||
// Create file digests for deployment
|
||||
const fileDigests: Record<string, string> = {};
|
||||
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
||||
fileDigests[path] = hash;
|
||||
}
|
||||
|
||||
// Create deployment
|
||||
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: fileDigests,
|
||||
async: false,
|
||||
draft: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (deployResponse.ok) {
|
||||
const deploy = (await deployResponse.json()) as any;
|
||||
|
||||
// Upload files
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
deployUrl = deploy.ssl_url || deploy.url || `https://${siteName}.netlify.app`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with authenticated deployment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Netlify Drop (no authentication required)
|
||||
if (!deployUrl) {
|
||||
// Create a form data with files
|
||||
const formData = new FormData();
|
||||
|
||||
// Add each file to the form data
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const fileName = path.startsWith('/') ? path.substring(1) : path;
|
||||
formData.append('file', blob, fileName);
|
||||
}
|
||||
|
||||
// Deploy using Netlify Drop API (no auth required)
|
||||
const dropResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (dropResponse.ok) {
|
||||
const dropData = (await dropResponse.json()) as any;
|
||||
siteId = dropData.id;
|
||||
deployUrl = dropData.ssl_url || dropData.url || `https://${dropData.subdomain}.netlify.app`;
|
||||
} else {
|
||||
// Try alternative deployment method
|
||||
const zipContent = await createZipArchive(deployFiles);
|
||||
|
||||
const zipResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
body: zipContent,
|
||||
});
|
||||
|
||||
if (zipResponse.ok) {
|
||||
const zipData = (await zipResponse.json()) as any;
|
||||
siteId = zipData.id;
|
||||
deployUrl = zipData.ssl_url || zipData.url;
|
||||
} else {
|
||||
throw new Error('Failed to deploy to Netlify');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deployUrl) {
|
||||
return json({ error: 'Deployment failed - could not get deployment URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
url: deployUrl,
|
||||
siteId,
|
||||
siteName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Quick deploy error:', error);
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Deployment failed',
|
||||
details: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a simple ZIP archive (minimal implementation)
|
||||
async function createZipArchive(files: Record<string, string>): Promise<ArrayBuffer> {
|
||||
// This is a simplified ZIP creation - in production, use a proper ZIP library
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
// For simplicity, we'll create a tar-like format
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const pathBytes = encoder.encode(path);
|
||||
const contentBytes = encoder.encode(content);
|
||||
|
||||
// Simple header: path length (4 bytes) + content length (4 bytes)
|
||||
const header = new Uint8Array(8);
|
||||
new DataView(header.buffer).setUint32(0, pathBytes.length, true);
|
||||
new DataView(header.buffer).setUint32(4, contentBytes.length, true);
|
||||
|
||||
parts.push(header);
|
||||
parts.push(pathBytes);
|
||||
parts.push(contentBytes);
|
||||
}
|
||||
|
||||
// Combine all parts
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { deleteUser } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return json({ error: 'User ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Prevent users from deleting themselves
|
||||
if (payload.userId === id) {
|
||||
return json({ error: 'Cannot delete your own account' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
// Delete the user
|
||||
const success = await deleteUser(id);
|
||||
|
||||
if (success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
} catch (error) {
|
||||
console.error('User operation error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getAllUsers } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all users (without passwords)
|
||||
const users = await getAllUsers();
|
||||
|
||||
return json({ users });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { login, signup } from '~/lib/stores/auth';
|
||||
import { validatePassword } from '~/lib/utils/crypto';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
export default function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
confirmPassword: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
const [avatar, setAvatar] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
// Clear error for this field
|
||||
setErrors((prev) => ({ ...prev, [name]: '' }));
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
setAvatar(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'signup') {
|
||||
// Validate form
|
||||
const validationErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username) {
|
||||
validationErrors.username = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.firstName) {
|
||||
validationErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(formData.password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
validationErrors.password = passwordValidation.errors[0];
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
validationErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signup(formData.username, formData.password, formData.firstName, avatar);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Signup failed' });
|
||||
}
|
||||
} else {
|
||||
const result = await login(formData.username, formData.password, formData.rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Invalid username or password' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setErrors({ general: 'An error occurred. Please try again.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
{/* Animated gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600">
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-30"
|
||||
animate={{
|
||||
background: [
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 80% 20%, #a855f7 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 40% 40%, #ec4899 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 10, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo and Title */}
|
||||
<div className="absolute top-8 left-8 z-20">
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<span className="text-2xl">⚡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">bolt.diy</h1>
|
||||
<p className="text-sm text-white/70">Multi-User Edition</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Auth Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative z-10 w-full max-w-md mx-4"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* Tab Header */}
|
||||
<div className="flex relative bg-white/5">
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'login'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('signup')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'signup'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
{/* Sliding indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 h-1 bg-gradient-to-r from-blue-500 to-purple-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: mode === 'login' ? '0%' : '100%',
|
||||
width: '50%',
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.form
|
||||
key={mode}
|
||||
initial={{ opacity: 0, x: mode === 'login' ? -20 : 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: mode === 'login' ? 20 : -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Avatar Upload (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur flex items-center justify-center overflow-hidden border-2 border-white/30">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl text-white/50">👤</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors border border-white/30">
|
||||
<span className="text-sm">📷</span>
|
||||
<input type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First Name (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.firstName && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
{errors.firstName && <p className="mt-1 text-sm text-red-300">{errors.firstName}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.username && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
{errors.username && <p className="mt-1 text-sm text-red-300">{errors.username}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.password && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-red-300">{errors.password}</p>}
|
||||
{mode === 'signup' && formData.password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
formData.password.length >= 8 ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">At least 8 characters</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[A-Z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One uppercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[a-z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One lowercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[0-9]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One number</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.confirmPassword && 'border-red-400',
|
||||
)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
{errors.confirmPassword && <p className="mt-1 text-sm text-red-300">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remember Me (Login only) */}
|
||||
{mode === 'login' && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
id="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-white/50"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
|
||||
Remember me for 7 days
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
|
||||
<p className="text-sm text-red-200">{errors.general}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={classNames(
|
||||
'w-full py-3 rounded-lg font-semibold transition-all',
|
||||
'bg-gradient-to-r from-blue-500 to-purple-600 text-white',
|
||||
'hover:from-blue-600 hover:to-purple-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
'shadow-lg hover:shadow-xl',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale w-5 h-5" />
|
||||
{mode === 'login' ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : mode === 'login' ? (
|
||||
'Sign In'
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Developer Credit */}
|
||||
<div className="mt-8 pt-6 border-t border-white/10">
|
||||
<p className="text-center text-xs text-white/40">
|
||||
Developed by <span className="text-white/60 font-medium">Keoma Wright</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Continue as Guest */}
|
||||
<div className="pb-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-2 text-center text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Continue as Guest
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,6 @@
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
@@ -98,7 +97,6 @@
|
||||
"@remix-run/node": "^2.15.2",
|
||||
"@remix-run/react": "^2.15.2",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@types/jszip": "^3.4.1",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
||||
"@unocss/reset": "^0.61.9",
|
||||
@@ -107,7 +105,6 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ai": "4.3.16",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chalk": "^5.4.1",
|
||||
"chart.js": "^4.4.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -126,7 +123,6 @@
|
||||
"istextorbinary": "^9.5.0",
|
||||
"jose": "^5.9.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jspdf": "^2.5.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
@@ -166,19 +162,16 @@
|
||||
"@cloudflare/workers-types": "^4.20241127.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@iconify-json/ph": "^1.2.1",
|
||||
"@iconify-json/simple-icons": "^1.2.49",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"@remix-run/dev": "^2.15.2",
|
||||
"@remix-run/serve": "^2.15.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/electron": "^1.6.12",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
|
||||
184
pnpm-lock.yaml
generated
184
pnpm-lock.yaml
generated
@@ -146,9 +146,6 @@ importers:
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.1.1
|
||||
version: 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -173,9 +170,6 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/jszip':
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
'@types/react-beautiful-dnd':
|
||||
specifier: ^13.1.8
|
||||
version: 13.1.8
|
||||
@@ -200,9 +194,6 @@ importers:
|
||||
ai:
|
||||
specifier: 4.3.16
|
||||
version: 4.3.16(react@18.3.1)(zod@3.25.76)
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
chalk:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1
|
||||
@@ -257,9 +248,6 @@ importers:
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
jspdf:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
@@ -372,9 +360,6 @@ importers:
|
||||
'@iconify-json/ph':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.2
|
||||
'@iconify-json/simple-icons':
|
||||
specifier: ^1.2.49
|
||||
version: 1.2.49
|
||||
'@iconify/types':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@@ -390,9 +375,6 @@ importers:
|
||||
'@testing-library/react':
|
||||
specifier: ^16.2.0
|
||||
version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
'@types/diff':
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
@@ -408,9 +390,6 @@ importers:
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
'@types/path-browserify':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
@@ -1974,9 +1953,6 @@ packages:
|
||||
'@iconify-json/ph@1.2.2':
|
||||
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
|
||||
|
||||
'@iconify-json/simple-icons@1.2.49':
|
||||
resolution: {integrity: sha512-nRLwrHzz+cTAQYBNQrcr4eWOmQIcHObTj/QSi7nj0SFwVh5MvBsgx8OhoDC/R8iGklNmMpmoE/NKU0cPXMlOZw==}
|
||||
|
||||
'@iconify-json/svg-spinners@1.2.2':
|
||||
resolution: {integrity: sha512-DIErwfBWWzLfmAG2oQnbUOSqZhDxlXvr8941itMCrxQoMB0Hiv8Ww6Bln/zIgxwjDvSem2dKJtap+yKKwsB/2A==}
|
||||
|
||||
@@ -2321,9 +2297,6 @@ packages:
|
||||
'@radix-ui/primitive@1.1.2':
|
||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
@@ -2629,19 +2602,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slider@1.3.6':
|
||||
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -3336,10 +3296,6 @@ packages:
|
||||
'@types/babel__traverse@7.20.7':
|
||||
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
|
||||
|
||||
'@types/bcryptjs@3.0.0':
|
||||
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
|
||||
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
@@ -3394,13 +3350,6 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/jszip@3.4.1':
|
||||
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
|
||||
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
|
||||
|
||||
@@ -3901,10 +3850,6 @@ packages:
|
||||
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
bcryptjs@3.0.2:
|
||||
resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==}
|
||||
hasBin: true
|
||||
|
||||
before-after-hook@3.0.2:
|
||||
resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==}
|
||||
|
||||
@@ -3998,9 +3943,6 @@ packages:
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -4562,9 +4504,6 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
editions@6.21.0:
|
||||
resolution: {integrity: sha512-ofkXJtn7z0urokN62DI3SBo/5xAtF0rR7tn+S/bSYV79Ka8pTajIIl+fFQ1q88DQEImymmo97M4azY3WX/nUdg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5647,22 +5586,12 @@ packages:
|
||||
jsonfile@6.1.0:
|
||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||
|
||||
jsonwebtoken@9.0.2:
|
||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jspdf@2.5.2:
|
||||
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
jwa@1.4.2:
|
||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||
|
||||
jws@3.2.2:
|
||||
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -5712,34 +5641,13 @@ packages:
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -9955,10 +9863,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/simple-icons@1.2.49':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/svg-spinners@1.2.2':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -10366,8 +10270,6 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -10685,25 +10587,6 @@ snapshots:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
@@ -11568,15 +11451,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@types/bcryptjs@3.0.0':
|
||||
dependencies:
|
||||
bcryptjs: 3.0.2
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
dependencies:
|
||||
'@types/http-cache-semantics': 4.0.4
|
||||
'@types/keyv': 3.1.4
|
||||
'@types/node': 24.1.0
|
||||
'@types/node': 20.19.9
|
||||
'@types/responselike': 1.0.3
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
@@ -11628,18 +11507,9 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 24.1.0
|
||||
|
||||
'@types/jszip@3.4.1':
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 24.1.0
|
||||
'@types/node': 20.19.9
|
||||
|
||||
'@types/mdast@3.0.15':
|
||||
dependencies:
|
||||
@@ -11700,7 +11570,7 @@ snapshots:
|
||||
|
||||
'@types/responselike@1.0.3':
|
||||
dependencies:
|
||||
'@types/node': 24.1.0
|
||||
'@types/node': 20.19.9
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
@@ -11713,7 +11583,7 @@ snapshots:
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 24.1.0
|
||||
'@types/node': 20.19.9
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.31.0(jiti@1.21.7))(typescript@5.8.3)':
|
||||
@@ -12319,8 +12189,6 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
bcryptjs@3.0.2: {}
|
||||
|
||||
before-after-hook@3.0.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
@@ -12458,8 +12326,6 @@ snapshots:
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer-xor@1.0.3: {}
|
||||
@@ -13088,10 +12954,6 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
editions@6.21.0:
|
||||
dependencies:
|
||||
version-range: 4.14.0
|
||||
@@ -14535,19 +14397,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonwebtoken@9.0.2:
|
||||
dependencies:
|
||||
jws: 3.2.2
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.2
|
||||
|
||||
jspdf@2.5.2:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
@@ -14567,17 +14416,6 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
jwa@1.4.2:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@3.2.2:
|
||||
dependencies:
|
||||
jwa: 1.4.2
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -14619,24 +14457,10 @@ snapshots:
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Set working directory
|
||||
cd /root/bolt
|
||||
|
||||
# Kill any existing processes on port 5173
|
||||
lsof -ti:5173 | xargs -r kill -9 2>/dev/null
|
||||
|
||||
# Set environment variables
|
||||
export NODE_OPTIONS="--max-old-space-size=3482"
|
||||
export HOST=0.0.0.0
|
||||
export PORT=5173
|
||||
# Don't set NODE_ENV for dev server
|
||||
unset NODE_ENV
|
||||
|
||||
echo "Starting Bolt.gives Server..."
|
||||
echo "========================================="
|
||||
echo "Version: 3.0.1"
|
||||
echo "Port: 5173"
|
||||
echo "Host: 0.0.0.0"
|
||||
echo "URL: https://bolt.openweb.live"
|
||||
echo "========================================="
|
||||
|
||||
# Use dev server with fixed dependencies
|
||||
exec pnpm run dev --host 0.0.0.0 --port 5173
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script to verify Ollama provider is properly enabled
|
||||
* when configured via environment variables
|
||||
*/
|
||||
|
||||
// Simulate environment configuration
|
||||
process.env.OLLAMA_API_BASE_URL = 'http://127.0.0.1:11434';
|
||||
|
||||
console.log('Testing Ollama Provider Auto-Enable Fix...\n');
|
||||
|
||||
// Test 1: Check environment detection in loader
|
||||
console.log('Test 1: Environment Detection');
|
||||
const hasOllamaEnv = process.env.OLLAMA_API_BASE_URL;
|
||||
console.log(`✓ OLLAMA_API_BASE_URL detected: ${hasOllamaEnv}`);
|
||||
|
||||
// Test 2: Verify provider configuration logic
|
||||
console.log('\nTest 2: Provider Configuration Logic');
|
||||
const configuredProviders = [];
|
||||
if (process.env.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
console.log(`✓ Ollama added to configured providers: ${configuredProviders.includes('Ollama')}`);
|
||||
|
||||
// Test 3: Simulate provider enablement
|
||||
console.log('\nTest 3: Provider Enablement Simulation');
|
||||
const mockProviderStore = {
|
||||
'Ollama': {
|
||||
name: 'Ollama',
|
||||
settings: { enabled: false }
|
||||
},
|
||||
'OpenRouter': {
|
||||
name: 'OpenRouter',
|
||||
settings: { enabled: true }
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate the fix logic
|
||||
const savedSettings = null; // Simulate no saved settings
|
||||
if (!savedSettings && configuredProviders.length > 0) {
|
||||
configuredProviders.forEach((providerName) => {
|
||||
if (mockProviderStore[providerName]) {
|
||||
mockProviderStore[providerName].settings.enabled = true;
|
||||
console.log(`✓ ${providerName} provider enabled automatically`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✓ Ollama final state - enabled: ${mockProviderStore['Ollama'].settings.enabled}`);
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Summary ===');
|
||||
console.log('All tests passed! The fix properly:');
|
||||
console.log('1. Detects Ollama configuration in environment variables');
|
||||
console.log('2. Adds Ollama to the list of configured providers');
|
||||
console.log('3. Automatically enables Ollama when no saved settings exist');
|
||||
console.log('\nIssue #1881 should now be resolved.');
|
||||
@@ -13,19 +13,6 @@ dotenv.config();
|
||||
|
||||
export default defineConfig((config) => {
|
||||
return {
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
hmr: {
|
||||
host: 'bolt.openweb.live',
|
||||
protocol: 'wss'
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Embedder-Policy': 'credentialless',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
},
|
||||
allowedHosts: ['bolt.openweb.live', 'localhost']
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user