50 Commits

Author SHA1 Message Date
Leex
64afda1078 Update README.md
Co-authored-by: patak <583075+patak-dev@users.noreply.github.com>
2025-03-18 11:40:49 +01:00
Leex
7107163f31 Update README.md 2025-03-18 10:54:39 +01:00
Leex
27fbfb76c4 Update README.md 2025-03-10 00:05:36 +01:00
Leex
a696d5f254 Update README.md 2025-03-09 23:44:23 +01:00
Leex
fc779b5b1f update: docs README.md
- changed git clone for pulling stable branch, not main anymore
- Removed some not needed stuff
2025-03-08 23:08:40 +01:00
Stijnus
50dd74de07 fix: settings bugfix error building my application issue #1414 (#1436)
* Fix: error building my application #1414

* fix for vite

* Update vite.config.ts

* Update root.tsx

* fix the root.tsx and the debugtab

* lm studio fix and fix for the api key

* Update api.enhancer for prompt enhancement

* bugfixes

* Revert api.enhancer.ts back to original code

* Update api.enhancer.ts

* Update api.git-proxy.$.ts

* Update api.git-proxy.$.ts

* Update api.enhancer.ts
2025-03-09 01:07:56 +05:30
Anirban Kar
7ff48e1d45 fix: attachment not getting sent on first message if starter template is turned on (#1472) 2025-03-08 12:44:46 +05:30
Anirban Kar
cd4a5e8380 fix: fix git proxy to work with other git provider (#1466) 2025-03-07 14:10:37 +05:30
Anirban Kar
9780393b17 fix: git cookies are auto set anytime connects changed or loaded (#1461) 2025-03-07 00:29:44 +05:30
Burhanuddin Khatri
20722a108c feat: add Claude 3.7 Sonnet model as static list and update API key reference (#1449) 2025-03-05 19:12:52 +05:30
Anirban Kar
1f940391b1 feat: restoring project from snapshot on reload (#444)
* feat:(project-snapshot) restoring project from snapshot on reload

* minor bugfix

* updated message

* added snapshot reload with auto run dev commands

* added message context

* snapshot updated
2025-03-05 10:59:48 +05:30
Anirban Kar
73a0f3ae24 fix: git clone modal to work with non main as default branch (#1428) 2025-03-05 04:23:01 +05:30
Anirban Kar
f9436d4929 ci: updated target for docker build (#1451) 2025-03-05 03:58:01 +05:30
Anirban Kar
55283065f5 Merge branch 'docker-fix' 2025-03-04 20:40:11 +05:30
Anirban Kar
2452f9413d ci: updated to have concise and parallel builds 2025-03-04 20:37:33 +05:30
Anirban Kar
9b2a204ddc ci: added arm64 build and tags build 2025-03-04 20:28:51 +05:30
Anirban Kar
8d1f138224 Revert "Delete wrangler.toml"
This reverts commit 60b6f476d7.
2025-03-03 21:21:04 +05:30
Leex
2780b2ebe1 Delete .tool-versions 2025-03-03 15:57:41 +01:00
Leex
60b6f476d7 Delete wrangler.toml 2025-03-03 15:57:25 +01:00
KevIsDev
964e1973fb fix: added a bunch more common languages to diff view
including: java, c, cpp, csharp, go ruby, rust
2025-03-03 09:39:16 +00:00
KevIsDev
b01874205e fix: support php language in diff view 2025-03-03 09:24:39 +00:00
Anirban Kar
4404f4a5c0 fix: git connection fix for starter template (#1411) 2025-03-02 12:58:13 +05:30
Anirban Kar
3368b7903e fix: OpenAILike api key not showing up (#1403) 2025-03-02 03:41:58 +05:30
bizrockman
8deee04a63 fix: handle empty content correctly in FilesStore saveFile() (#1381)
* Fix FilesStore: Handle empty content correctly in saveFile(). Happens when user creates files in the web terminal

* updated logic

---------

Co-authored-by: Anirban Kar <thecodacus@gmail.com>
2025-03-01 02:34:39 +05:30
github-actions[bot]
b7c8677cb8 chore: release version 0.0.7 2025-02-28 20:13:14 +00:00
Anirban Kar
6c5d094ed7 ci: fixed bug with release notes on github release action (#1401) #release 2025-03-01 01:42:41 +05:30
Anirban Kar
3c28e8ad88 fix: fix enhance prompt to stop implementing full project instead of enhancing (#1383) #release
* fix: enhance prompt fix

* fix: added error capture on api error

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

* fix: remove artifact from user message on the UI

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

View File

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

View File

@@ -1,14 +1,14 @@
---
name: Docker Publish
on:
workflow_dispatch:
push:
branches:
- main
tags:
- v*
- '*'
branches: [main, stable]
tags: ['v*', '*.*.*']
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
packages: write
@@ -16,66 +16,47 @@ permissions:
env:
REGISTRY: ghcr.io
DOCKER_IMAGE: ghcr.io/${{ github.repository }}
BUILD_TARGET: bolt-ai-production # bolt-ai-development
IMAGE_NAME: ${{ github.repository }}
jobs:
docker-build-publish:
runs-on: ubuntu-latest
# timeout-minutes: 30
steps:
- name: Checkout
- name: Checkout code
uses: actions/checkout@v4
- id: string
uses: ASzc/change-string-case-action@v6
with:
string: ${{ env.DOCKER_IMAGE }}
- name: Docker meta
id: meta
uses: crazy-max/ghaction-docker-meta@v5
with:
images: ${{ steps.string.outputs.lowercase }}
flavor: |
latest=true
prefix=
suffix=
tags: |
type=semver,pattern={{version}}
type=pep440,pattern={{version}}
type=ref,event=tag
type=raw,value={{sha}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }}
type=ref,event=tag
type=sha,format=short
type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: ${{ env.BUILD_TARGET }}
platforms: linux/amd64,linux/arm64
target: bolt-ai-production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
cache-to: type=inline
- name: Check manifest
run: |
docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }}
- name: Dump context
if: always()
uses: crazy-max/ghaction-dump-context@v2
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

View File

@@ -119,7 +119,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="v${{ steps.bump_version.outputs.new_version }}"
# Save changelog to a file
echo "${{ steps.changelog.outputs.content }}" > release_notes.md
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.content }}" \
--notes-file release_notes.md \
--target stable

1
.gitignore vendored
View File

@@ -43,5 +43,4 @@ app/commit.json
changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
.cursorrules
*.md

View File

@@ -1,2 +0,0 @@
nodejs 20.15.1
pnpm 9.4.0

View File

@@ -6,9 +6,10 @@ WORKDIR /app
# Install dependencies (this step is cached as long as the dependencies don't change)
COPY package.json pnpm-lock.yaml ./
RUN npm install -g corepack@latest
#RUN npm install -g corepack@latest
RUN corepack enable pnpm && pnpm install
#RUN corepack enable pnpm && pnpm install
RUN npm install -g pnpm && pnpm install
# Copy the rest of your app's source code
COPY . .

View File

@@ -1,15 +1,13 @@
# bolt.diy (Previously oTToDev)
# bolt.diy
[![bolt.diy: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.diy)
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
---
Welcome to bolt.diy, the official open source version of Bolt.new, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
-----
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
---
-----
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).
@@ -81,10 +79,12 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- ✅ Add Starter Template Options (@thecodacus)
- ✅ Perplexity Integration (@meetpateltech)
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
-**HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
-**HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
-**HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
- Deploy directly to Vercel/Netlify/other similar platforms
- Deploy directly to Netlify (@xKevIsDev)
- ⬜ Supabase Integration
- ⬜ Have LLM plan the project in a MD file for better results/transparency
- ⬜ VSCode Integration with git-like confirmations
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
@@ -102,8 +102,9 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- **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.
- **Download projects as ZIP** for easy portability Sync to a folder on the host.
- **Integration-ready Docker support** for a hassle-free setup.
- **Deploy** directly to **Netlify**
## Setup
@@ -242,8 +243,7 @@ This method is recommended for developers who want to:
1. **Clone the Repository**:
```bash
# Using HTTPS
git clone https://github.com/stackblitz-labs/bolt.diy.git
git clone -b stable https://github.com/stackblitz-labs/bolt.diy.git
```
2. **Navigate to Project Directory**:
@@ -252,21 +252,31 @@ This method is recommended for developers who want to:
cd bolt.diy
```
3. **Switch to the Main Branch**:
```bash
git checkout main
```
4. **Install Dependencies**:
3. **Install Dependencies**:
```bash
pnpm install
```
5. **Start the Development Server**:
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)**
> - Beginngers:
> - 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:
@@ -280,7 +290,7 @@ To get the latest changes from the repository:
2. **Pull Latest Updates**:
```bash
git pull origin main
git pull
```
3. **Update Dependencies**:
@@ -350,3 +360,9 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
## 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.

View File

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

View File

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

View File

@@ -0,0 +1,263 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import {
netlifyConnection,
isConnecting,
isFetchingStats,
updateNetlifyConnection,
fetchNetlifyStats,
} from '~/lib/stores/netlify';
import type { NetlifyUser } from '~/types/netlify';
export default function NetlifyConnection() {
const connection = useStore(netlifyConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
useEffect(() => {
const fetchSites = async () => {
if (connection.user && connection.token) {
await fetchNetlifyStats(connection.token);
}
};
fetchSites();
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const userData = (await response.json()) as NetlifyUser;
updateNetlifyConnection({
user: userData,
token: connection.token,
});
await fetchNetlifyStats(connection.token);
toast.success('Successfully connected to Netlify');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Netlify', { error });
toast.error('Failed to connect to Netlify');
updateNetlifyConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateNetlifyConnection({ user: null, token: '' });
toast.success('Disconnected from Netlify');
};
return (
<motion.div
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
</div>
</div>
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Netlify personal access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#00AD9F] text-white',
'hover:bg-[#00968A]',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to Netlify
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<img
src={connection.user.avatar_url}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt={connection.user.full_name}
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Netlify sites...
</div>
) : (
<div>
<button
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings w-4 h-4" />
Your Sites ({connection.stats?.totalSites || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isSitesExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isSitesExpanded && connection.stats?.sites?.length ? (
<div className="grid gap-3">
{connection.stats.sites.map((site) => (
<a
key={site.id}
href={site.admin_url}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
{site.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url}
</a>
{site.published_deploy && (
<>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(site.published_deploy.published_at).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
{site.build_settings?.provider && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:git-branch w-3 h-3" />
{site.build_settings.provider}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isSitesExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No sites found in your Netlify account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -292,11 +292,24 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
// Fetch repository tree
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
const repoObjData = (await repoObjResponse.json()) as any;
if (!repoObjData.default_branch) {
throw new Error('Failed to fetch repository branch');
}
const defaultBranch = repoObjData.default_branch;
// Fetch repository tree
const treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{
headers,
},
);
if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure');

View File

@@ -34,6 +34,7 @@ import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -45,6 +46,7 @@ interface BaseChatProps {
showChat?: boolean;
chatStarted?: boolean;
isStreaming?: boolean;
onStreamingChange?: (streaming: boolean) => void;
messages?: Message[];
description?: string;
enhancingPrompt?: boolean;
@@ -68,6 +70,7 @@ interface BaseChatProps {
actionAlert?: ActionAlert;
clearAlert?: () => void;
data?: JSONValue[] | undefined;
actionRunner?: ActionRunner;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -79,6 +82,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
showChat = true,
chatStarted = false,
isStreaming = false,
onStreamingChange,
model,
setModel,
provider,
@@ -102,6 +106,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
actionAlert,
clearAlert,
data,
actionRunner,
},
ref,
) => {
@@ -126,6 +131,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
console.log(transcript);
}, [transcript]);
useEffect(() => {
onStreamingChange?.(isStreaming);
}, [isStreaming, onStreamingChange]);
useEffect(() => {
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -304,7 +313,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
data-chat-visible={showChat}
>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
@@ -318,40 +327,39 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
)}
<div
className={classNames('pt-6 px-2 sm:px-6', {
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
'h-full flex flex-col': chatStarted,
})}
ref={scrollRef}
>
<ClientOnly>
{() => {
return chatStarted ? (
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
<Messages
ref={messageRef}
className="flex flex-col "
messages={messages}
isStreaming={isStreaming}
/>
</div>
<Messages
ref={messageRef}
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
'position-absolute': chatStarted,
})}
>
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
<div className="bg-bolt-elements-background-depth-2">
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
</div>
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<div
className={classNames(
@@ -405,15 +413,17 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
apiKeys={apiKeys}
modelLoading={isModelLoading}
/>
{(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => {
onApiKeysChange(provider.name, key);
}}
/>
)}
{(providerList || []).length > 0 &&
provider &&
(!LOCAL_PROVIDERS.includes(provider.name) || 'OpenAILike') && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => {
onApiKeysChange(provider.name, key);
}}
/>
)}
</div>
)}
</ClientOnly>
@@ -585,16 +595,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
</div>
</div>
{!chatStarted && (
<div className="flex flex-col justify-center mt-6 gap-5">
<div className="flex flex-col justify-center gap-5">
{!chatStarted && (
<div className="flex justify-center gap-2">
<div className="flex items-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
</div>
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
{ExamplePrompts((event, messageInput) => {
)}
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {
handleStop?.();
return;
@@ -602,11 +611,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput);
})}
<StarterTemplates />
</div>
)}
{!chatStarted && <StarterTemplates />}
</div>
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
<ClientOnly>
{() => (
<Workbench
actionRunner={actionRunner ?? ({} as ActionRunner)}
chatStarted={chatStarted}
isStreaming={isStreaming}
/>
)}
</ClientOnly>
</div>
</div>
);

View File

@@ -24,6 +24,8 @@ import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -319,23 +321,41 @@ export const ChatImpl = memo(
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `${new Date().getTime()}`,
id: `1-${new Date().getTime()}`,
role: 'user',
content: messageContent,
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
},
{
id: `${new Date().getTime()}`,
id: `2-${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `${new Date().getTime()}`,
id: `3-${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
setFakeLoading(false);
return;
@@ -362,6 +382,15 @@ export const ChatImpl = memo(
]);
reload();
setFakeLoading(false);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
return;
}
@@ -370,17 +399,18 @@ export const ChatImpl = memo(
setMessages(messages.slice(0, -1));
}
const fileModifications = workbenchStore.getFileModifcations();
const modifiedFiles = workbenchStore.getModifiedFiles();
chatStore.setKey('aborted', false);
if (fileModifications !== undefined) {
if (modifiedFiles !== undefined) {
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
@@ -465,6 +495,9 @@ export const ChatImpl = memo(
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading || fakeLoading}
onStreamingChange={(streaming) => {
streamingState.set(streaming);
}}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}

View File

@@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
>
<div className="flex items-start">
{/* Icon */}

View File

@@ -70,7 +70,7 @@ export default function GitCloneButton({ importChat, className }: GitCloneButton
// Skip binary files
if (
content instanceof Uint8Array &&
!filePath.match(/\.(txt|md|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
!filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
) {
skippedFiles.push(filePath);
continue;

View File

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

View File

@@ -42,7 +42,6 @@ export default function ProgressCompilation({ data }: { data?: ProgressAnnotatio
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
'p-1',
)}
style={{ transform: 'translateY(1rem)' }}
>
<div
className={classNames(

View File

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

View File

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

View File

@@ -4,15 +4,11 @@ import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { genericMemo } from '~/utils/react';
interface SliderOption<T> {
value: T;
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
export type SliderOptions<T> = {
left: { value: T; text: string };
middle?: { value: T; text: string };
right: { value: T; text: string };
};
interface SliderProps<T> {
selected: T;
@@ -21,14 +17,26 @@ interface SliderProps<T> {
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
const hasMiddle = !!options.middle;
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
{options.left.text}
</SliderButton>
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.middle && (
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
{options.middle.text}
</SliderButton>
)}
<SliderButton
selected={!isLeftSelected && !isMiddleSelected}
setSelected={() => setSelected?.(options.right.value)}
>
{options.right.text}
</SliderButton>
</div>

View File

@@ -0,0 +1,755 @@
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import type { FileMap } from '~/lib/stores/files';
import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
import { diffLines, type Change } from 'diff';
import { getHighlighter } from 'shiki';
import '~/styles/diff-view.css';
import { diffFiles, extractRelativePath } from '~/utils/diff';
import { ActionRunner } from '~/lib/runtime/action-runner';
import type { FileHistory } from '~/types/actions';
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
import { themeStore } from '~/lib/stores/theme';
interface CodeComparisonProps {
beforeCode: string;
afterCode: string;
language: string;
filename: string;
lightTheme: string;
darkTheme: string;
}
interface DiffBlock {
lineNumber: number;
content: string;
type: 'added' | 'removed' | 'unchanged';
correspondingLine?: number;
charChanges?: Array<{
value: string;
type: 'added' | 'removed' | 'unchanged';
}>;
}
interface FullscreenButtonProps {
onClick: () => void;
isFullscreen: boolean;
}
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
<button
onClick={onClick}
className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
>
<div className={isFullscreen ? 'i-ph:corners-in' : 'i-ph:corners-out'} />
</button>
));
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
if (!isFullscreen) {
return <>{children}</>;
}
return (
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
<div className="w-full h-full max-w-[90vw] max-h-[90vh] bg-bolt-elements-background-depth-2 rounded-lg border border-bolt-elements-borderColor shadow-xl overflow-hidden">
{children}
</div>
</div>
);
});
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
const isBinaryFile = (content: string) => {
return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
};
const processChanges = (beforeCode: string, afterCode: string) => {
try {
if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
return {
beforeLines: [],
afterLines: [],
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
isBinary: true,
};
}
// Normalize line endings and content
const normalizeContent = (content: string): string[] => {
return content
.replace(/\r\n/g, '\n')
.split('\n')
.map((line) => line.trimEnd());
};
const beforeLines = normalizeContent(beforeCode);
const afterLines = normalizeContent(afterCode);
// Early return if files are identical
if (beforeLines.join('\n') === afterLines.join('\n')) {
return {
beforeLines,
afterLines,
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
isBinary: false,
};
}
const lineChanges = {
before: new Set<number>(),
after: new Set<number>(),
};
const unifiedBlocks: DiffBlock[] = [];
// Compare lines directly for more accurate diff
let i = 0,
j = 0;
while (i < beforeLines.length || j < afterLines.length) {
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
// Unchanged line
unifiedBlocks.push({
lineNumber: j,
content: afterLines[j],
type: 'unchanged',
correspondingLine: i,
});
i++;
j++;
} else {
// Look ahead for potential matches
let matchFound = false;
const lookAhead = 3; // Number of lines to look ahead
// Try to find matching lines ahead
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
if (beforeLines[i + k] === afterLines[j]) {
// Found match in after lines - mark lines as removed
for (let l = 0; l < k; l++) {
lineChanges.before.add(i + l);
unifiedBlocks.push({
lineNumber: i + l,
content: beforeLines[i + l],
type: 'removed',
correspondingLine: j,
charChanges: [{ value: beforeLines[i + l], type: 'removed' }],
});
}
i += k;
matchFound = true;
break;
} else if (beforeLines[i] === afterLines[j + k]) {
// Found match in before lines - mark lines as added
for (let l = 0; l < k; l++) {
lineChanges.after.add(j + l);
unifiedBlocks.push({
lineNumber: j + l,
content: afterLines[j + l],
type: 'added',
correspondingLine: i,
charChanges: [{ value: afterLines[j + l], type: 'added' }],
});
}
j += k;
matchFound = true;
break;
}
}
if (!matchFound) {
// No match found - try to find character-level changes
if (i < beforeLines.length && j < afterLines.length) {
const beforeLine = beforeLines[i];
const afterLine = afterLines[j];
// Find common prefix and suffix
let prefixLength = 0;
while (
prefixLength < beforeLine.length &&
prefixLength < afterLine.length &&
beforeLine[prefixLength] === afterLine[prefixLength]
) {
prefixLength++;
}
let suffixLength = 0;
while (
suffixLength < beforeLine.length - prefixLength &&
suffixLength < afterLine.length - prefixLength &&
beforeLine[beforeLine.length - 1 - suffixLength] === afterLine[afterLine.length - 1 - suffixLength]
) {
suffixLength++;
}
const prefix = beforeLine.slice(0, prefixLength);
const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
const suffix = beforeLine.slice(beforeLine.length - suffixLength);
if (beforeMiddle || afterMiddle) {
// There are character-level changes
if (beforeMiddle) {
lineChanges.before.add(i);
unifiedBlocks.push({
lineNumber: i,
content: beforeLine,
type: 'removed',
correspondingLine: j,
charChanges: [
{ value: prefix, type: 'unchanged' },
{ value: beforeMiddle, type: 'removed' },
{ value: suffix, type: 'unchanged' },
],
});
i++;
}
if (afterMiddle) {
lineChanges.after.add(j);
unifiedBlocks.push({
lineNumber: j,
content: afterLine,
type: 'added',
correspondingLine: i - 1,
charChanges: [
{ value: prefix, type: 'unchanged' },
{ value: afterMiddle, type: 'added' },
{ value: suffix, type: 'unchanged' },
],
});
j++;
}
} else {
// No character-level changes found, treat as regular line changes
if (i < beforeLines.length) {
lineChanges.before.add(i);
unifiedBlocks.push({
lineNumber: i,
content: beforeLines[i],
type: 'removed',
correspondingLine: j,
charChanges: [{ value: beforeLines[i], type: 'removed' }],
});
i++;
}
if (j < afterLines.length) {
lineChanges.after.add(j);
unifiedBlocks.push({
lineNumber: j,
content: afterLines[j],
type: 'added',
correspondingLine: i - 1,
charChanges: [{ value: afterLines[j], type: 'added' }],
});
j++;
}
}
} else {
// Handle remaining lines
if (i < beforeLines.length) {
lineChanges.before.add(i);
unifiedBlocks.push({
lineNumber: i,
content: beforeLines[i],
type: 'removed',
correspondingLine: j,
charChanges: [{ value: beforeLines[i], type: 'removed' }],
});
i++;
}
if (j < afterLines.length) {
lineChanges.after.add(j);
unifiedBlocks.push({
lineNumber: j,
content: afterLines[j],
type: 'added',
correspondingLine: i - 1,
charChanges: [{ value: afterLines[j], type: 'added' }],
});
j++;
}
}
}
}
}
// Sort blocks by line number
const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
return {
beforeLines,
afterLines,
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
lineChanges,
unifiedBlocks: processedBlocks,
isBinary: false,
};
} catch (error) {
console.error('Error processing changes:', error);
return {
beforeLines: [],
afterLines: [],
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
error: true,
isBinary: false,
};
}
};
const lineNumberStyles =
'w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1';
const lineContentStyles =
'px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary';
const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
// Updated color styles for better consistency
const diffLineStyles = {
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
unchanged: '',
};
const changeColorStyles = {
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
unchanged: 'text-bolt-elements-textPrimary',
};
const renderContentWarning = (type: 'binary' | 'error') => (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-bolt-elements-textTertiary">
<div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
<p className="font-medium text-bolt-elements-textPrimary">
{type === 'binary' ? 'Binary file detected' : 'Error processing file'}
</p>
<p className="text-sm mt-1">
{type === 'binary' ? 'Diff view is not available for binary files' : 'Could not generate diff preview'}
</p>
</div>
</div>
);
const NoChangesView = memo(
({
beforeCode,
language,
highlighter,
theme,
}: {
beforeCode: string;
language: string;
highlighter: any;
theme: string;
}) => (
<div className="h-full flex flex-col items-center justify-center p-4">
<div className="text-center text-bolt-elements-textTertiary">
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
<p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
<p className="text-sm mt-1">Both versions match exactly</p>
</div>
<div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
<div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
Current Content
</div>
<div className="overflow-auto max-h-96">
{beforeCode.split('\n').map((line, index) => (
<div key={index} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={lineContentStyles}>
<span className="mr-2"> </span>
<span
dangerouslySetInnerHTML={{
__html: highlighter
? highlighter
.codeToHtml(line, {
lang: language,
theme: theme === 'dark' ? 'github-dark' : 'github-light',
})
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: line,
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
),
);
// Otimização do processamento de diferenças com memoização
const useProcessChanges = (beforeCode: string, afterCode: string) => {
return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
};
// Componente otimizado para renderização de linhas de código
const CodeLine = memo(
({
lineNumber,
content,
type,
highlighter,
language,
block,
theme,
}: {
lineNumber: number;
content: string;
type: 'added' | 'removed' | 'unchanged';
highlighter: any;
language: string;
block: DiffBlock;
theme: string;
}) => {
const bgColor = diffLineStyles[type];
const renderContent = () => {
if (type === 'unchanged' || !block.charChanges) {
const highlightedCode = highlighter
? highlighter
.codeToHtml(content, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: content;
return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
}
return (
<>
{block.charChanges.map((change, index) => {
const changeClass = changeColorStyles[change.type];
const highlightedCode = highlighter
? highlighter
.codeToHtml(change.value, {
lang: language,
theme: theme === 'dark' ? 'github-dark' : 'github-light',
})
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: change.value;
return <span key={index} className={changeClass} dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
})}
</>
);
};
return (
<div className="flex group min-w-fit">
<div className={lineNumberStyles}>{lineNumber + 1}</div>
<div className={`${lineContentStyles} ${bgColor}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{type === 'added' && <span className="text-green-700 dark:text-green-500">+</span>}
{type === 'removed' && <span className="text-red-700 dark:text-red-500">-</span>}
{type === 'unchanged' && ' '}
</span>
{renderContent()}
</div>
</div>
);
},
);
// Componente para exibir informações sobre o arquivo
const FileInfo = memo(
({
filename,
hasChanges,
onToggleFullscreen,
isFullscreen,
beforeCode,
afterCode,
}: {
filename: string;
hasChanges: boolean;
onToggleFullscreen: () => void;
isFullscreen: boolean;
beforeCode: string;
afterCode: string;
}) => {
// Calculate additions and deletions from the current document
const { additions, deletions } = useMemo(() => {
if (!hasChanges) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(beforeCode, afterCode, {
newlineIsToken: false,
ignoreWhitespace: true,
ignoreCase: false,
});
return changes.reduce(
(acc: { additions: number; deletions: number }, change: Change) => {
if (change.added) {
acc.additions += change.value.split('\n').length;
}
if (change.removed) {
acc.deletions += change.value.split('\n').length;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
}, [hasChanges, beforeCode, afterCode]);
const showStats = additions > 0 || deletions > 0;
return (
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
<span className="truncate">{filename}</span>
<span className="ml-auto shrink-0 flex items-center gap-2">
{hasChanges ? (
<>
{showStats && (
<div className="flex items-center gap-1 text-xs">
{additions > 0 && <span className="text-green-700 dark:text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-700 dark:text-red-500">-{deletions}</span>}
</div>
)}
<span className="text-yellow-600 dark:text-yellow-400">Modified</span>
<span className="text-bolt-elements-textTertiary text-xs">{new Date().toLocaleTimeString()}</span>
</>
) : (
<span className="text-green-700 dark:text-green-400">No Changes</span>
)}
<FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
</span>
</div>
);
},
);
const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }: CodeComparisonProps) => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [highlighter, setHighlighter] = useState<any>(null);
const theme = useStore(themeStore);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
useEffect(() => {
getHighlighter({
themes: ['github-dark', 'github-light'],
langs: [
'typescript',
'javascript',
'json',
'html',
'css',
'jsx',
'tsx',
'python',
'php',
'java',
'c',
'cpp',
'csharp',
'go',
'ruby',
'rust',
'plaintext',
],
}).then(setHighlighter);
}, []);
if (isBinary || error) {
return renderContentWarning(isBinary ? 'binary' : 'error');
}
return (
<FullscreenOverlay isFullscreen={isFullscreen}>
<div className="w-full h-full flex flex-col">
<FileInfo
filename={filename}
hasChanges={hasChanges}
onToggleFullscreen={toggleFullscreen}
isFullscreen={isFullscreen}
beforeCode={beforeCode}
afterCode={afterCode}
/>
<div className={diffPanelStyles}>
{hasChanges ? (
<div className="overflow-x-auto min-w-full">
{unifiedBlocks.map((block, index) => (
<CodeLine
key={`${block.lineNumber}-${index}`}
lineNumber={block.lineNumber}
content={block.content}
type={block.type}
highlighter={highlighter}
language={language}
block={block}
theme={theme}
/>
))}
</div>
) : (
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
)}
</div>
</div>
</FullscreenOverlay>
);
});
interface DiffViewProps {
fileHistory: Record<string, FileHistory>;
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
actionRunner: ActionRunner;
}
export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) => {
const files = useStore(workbenchStore.files) as FileMap;
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
useEffect(() => {
if (selectedFile && currentDocument) {
const file = files[selectedFile];
if (!file || !('content' in file)) {
return;
}
const existingHistory = fileHistory[selectedFile];
const currentContent = currentDocument.value;
// Normalizar o conteúdo para comparação
const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
const normalizedOriginalContent = (existingHistory?.originalContent || file.content)
.replace(/\r\n/g, '\n')
.trim();
// Se não há histórico existente, criar um novo apenas se houver diferenças
if (!existingHistory) {
if (normalizedCurrentContent !== normalizedOriginalContent) {
const newChanges = diffLines(file.content, currentContent);
setFileHistory((prev) => ({
...prev,
[selectedFile]: {
originalContent: file.content,
lastModified: Date.now(),
changes: newChanges,
versions: [
{
timestamp: Date.now(),
content: currentContent,
},
],
changeSource: 'auto-save',
},
}));
}
return;
}
// Se já existe histórico, verificar se há mudanças reais desde a última versão
const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
if (normalizedCurrentContent === normalizedLastContent) {
return; // Não criar novo histórico se o conteúdo é o mesmo
}
// Verificar se há mudanças significativas usando diffFiles
const relativePath = extractRelativePath(selectedFile);
const unifiedDiff = diffFiles(relativePath, existingHistory.originalContent, currentContent);
if (unifiedDiff) {
const newChanges = diffLines(existingHistory.originalContent, currentContent);
// Verificar se as mudanças são significativas
const hasSignificantChanges = newChanges.some(
(change) => (change.added || change.removed) && change.value.trim().length > 0,
);
if (hasSignificantChanges) {
const newHistory: FileHistory = {
originalContent: existingHistory.originalContent,
lastModified: Date.now(),
changes: [...existingHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
versions: [
...existingHistory.versions,
{
timestamp: Date.now(),
content: currentContent,
},
].slice(-10), // Manter apenas as 10 últimas versões
changeSource: 'auto-save',
};
setFileHistory((prev) => ({ ...prev, [selectedFile]: newHistory }));
}
}
}
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
if (!selectedFile || !currentDocument) {
return (
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
Select a file to view differences
</div>
);
}
const file = files[selectedFile];
const originalContent = file && 'content' in file ? file.content : '';
const currentContent = currentDocument.value;
const history = fileHistory[selectedFile];
const effectiveOriginalContent = history?.originalContent || originalContent;
const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
try {
return (
<div className="h-full overflow-hidden">
<InlineDiffComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
</div>
);
} catch (error) {
console.error('DiffView render error:', error);
return (
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
<div className="text-center">
<div className="i-ph:warning-circle text-4xl mb-2" />
<p>Failed to render diff view</p>
</div>
</div>
);
}
});

View File

@@ -12,6 +12,7 @@ import {
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import type { FileMap } from '~/lib/stores/files';
import type { FileHistory } from '~/types/actions';
import { themeStore } from '~/lib/stores/theme';
import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger';
@@ -27,6 +28,7 @@ interface EditorPanelProps {
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
fileHistory?: Record<string, FileHistory>;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
@@ -45,6 +47,7 @@ export const EditorPanel = memo(
editorDocument,
selectedFile,
isStreaming,
fileHistory,
onFileSelect,
onEditorChange,
onEditorScroll,
@@ -83,6 +86,7 @@ export const EditorPanel = memo(
files={files}
hideRoot
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}

View File

@@ -3,6 +3,8 @@ import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import * as ContextMenu from '@radix-ui/react-context-menu';
import type { FileHistory } from '~/types/actions';
import { diffLines, type Change } from 'diff';
const logger = createScopedLogger('FileTree');
@@ -19,6 +21,7 @@ interface Props {
allowFolderSelection?: boolean;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
fileHistory?: Record<string, FileHistory>;
className?: string;
}
@@ -34,6 +37,7 @@ export const FileTree = memo(
hiddenFiles,
className,
unsavedFiles,
fileHistory = {},
}: Props) => {
renderLogger.trace('FileTree');
@@ -138,6 +142,7 @@ export const FileTree = memo(
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
fileHistory={fileHistory}
onCopyPath={() => {
onCopyPath(fileOrFolder);
}}
@@ -253,19 +258,64 @@ interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
fileHistory?: Record<string, FileHistory>;
onCopyPath: () => void;
onCopyRelativePath: () => void;
onClick: () => void;
}
function File({
file: { depth, name },
file: { depth, name, fullPath },
onClick,
onCopyPath,
onCopyRelativePath,
selected,
unsavedChanges = false,
fileHistory = {},
}: FileProps) {
const fileModifications = fileHistory[fullPath];
// const hasModifications = fileModifications !== undefined;
// Calculate added and removed lines from the most recent changes
const { additions, deletions } = useMemo(() => {
if (!fileModifications?.originalContent) {
return { additions: 0, deletions: 0 };
}
// Usar a mesma lógica do DiffView para processar as mudanças
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
newlineIsToken: false,
ignoreWhitespace: true,
ignoreCase: false,
});
return changes.reduce(
(acc: { additions: number; deletions: number }, change: Change) => {
if (change.added) {
acc.additions += change.value.split('\n').length;
}
if (change.removed) {
acc.deletions += change.value.split('\n').length;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
}, [fileModifications]);
const showStats = additions > 0 || deletions > 0;
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
<NodeButton
@@ -286,7 +336,15 @@ function File({
})}
>
<div className="flex-1 truncate pr-2">{name}</div>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
<div className="flex items-center gap-1">
{showStats && (
<div className="flex items-center gap-1 text-xs">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)}
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</div>
</NodeButton>
</FileContextMenu>

View File

@@ -1,8 +1,14 @@
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
import { toast } from 'react-toastify';
import { Popover, Transition } from '@headlessui/react';
import { diffLines, type Change } from 'diff';
import { ActionRunner } from '~/lib/runtime/action-runner';
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
import type { FileHistory } from '~/types/actions';
import { DiffView } from './DiffView';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
@@ -22,6 +28,11 @@ import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/comp
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
actionRunner: ActionRunner;
metadata?: {
gitUrl?: string;
};
updateChatMestaData?: (metadata: any) => void;
}
const viewTransition = { ease: cubicEasingFn };
@@ -31,6 +42,10 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
value: 'code',
text: 'Code',
},
middle: {
value: 'diff',
text: 'Diff',
},
right: {
value: 'preview',
text: 'Preview',
@@ -54,182 +69,413 @@ const workbenchVariants = {
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const FileModifiedDropdown = memo(
({
fileHistory,
onSelectFile,
}: {
fileHistory: Record<string, FileHistory>;
onSelectFile: (filePath: string) => void;
}) => {
const modifiedFiles = Object.entries(fileHistory);
const hasChanges = modifiedFiles.length > 0;
const [searchQuery, setSearchQuery] = useState('');
const [isSyncing, setIsSyncing] = useState(false);
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const filteredFiles = useMemo(() => {
return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase()));
}, [modifiedFiles, searchQuery]);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const isSmallViewport = useViewport(1024);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch (error) {
console.error('Error syncing files:', error);
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-2 lg:px-6">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.downloadZip();
}}
>
<div className="i-ph:code" />
Download Code
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
<div className="i-ph:git-branch" />
Push to GitHub
</PanelHeaderButton>
</div>
return (
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open }: { open: boolean }) => (
<>
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textPrimary border border-bolt-elements-borderColor">
<span className="font-medium">File Changes</span>
{hasChanges && (
<span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
{modifiedFiles.length}
</span>
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
</Popover.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor">
<div className="p-2">
<div className="relative mx-2 mb-2">
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
<div className="i-ph:magnifying-glass" />
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredFiles.length > 0 ? (
filteredFiles.map(([filePath, history]) => {
const extension = filePath.split('.').pop() || '';
const language = getLanguageFromExtension(extension);
return (
<button
key={filePath}
onClick={() => onSelectFile(filePath)}
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
>
<div className="flex items-center gap-2">
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && (
<div className="i-ph:file-js" />
)}
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
{language === 'html' && <div className="i-ph:code" />}
{language === 'json' && <div className="i-ph:brackets-curly" />}
{language === 'python' && <div className="i-ph:file-text" />}
{language === 'markdown' && <div className="i-ph:article" />}
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
{language === 'sql' && <div className="i-ph:database" />}
{language === 'dockerfile' && <div className="i-ph:cube" />}
{language === 'shell' && <div className="i-ph:terminal" />}
{![
'typescript',
'javascript',
'css',
'html',
'json',
'python',
'markdown',
'yaml',
'yml',
'sql',
'dockerfile',
'shell',
'jsx',
'tsx',
'scss',
'less',
].includes(language) && <div className="i-ph:file-text" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
{filePath.split('/').pop()}
</span>
<span className="truncate text-xs text-bolt-elements-textTertiary">
{filePath}
</span>
</div>
{(() => {
// Calculate diff stats
const { additions, deletions } = (() => {
if (!history.originalContent) {
return { additions: 0, deletions: 0 };
}
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
history.versions[history.versions.length - 1]?.content.replace(
/\r\n/g,
'\n',
) || '';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
newlineIsToken: false,
ignoreWhitespace: true,
ignoreCase: false,
});
return changes.reduce(
(acc: { additions: number; deletions: number }, change: Change) => {
if (change.added) {
acc.additions += change.value.split('\n').length;
}
if (change.removed) {
acc.deletions += change.value.split('\n').length;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
})();
const showStats = additions > 0 || deletions > 0;
return (
showStats && (
<div className="flex items-center gap-1 text-xs shrink-0">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)
);
})()}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
<div className="i-ph:file-dashed" />
</div>
<p className="text-sm font-medium text-bolt-elements-textPrimary">
{searchQuery ? 'No matching files' : 'No modified files'}
</p>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
</p>
</div>
)}
</div>
</div>
{hasChanges && (
<div className="border-t border-bolt-elements-borderColor p-2">
<button
onClick={() => {
navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n'));
toast('File list copied to clipboard', {
icon: <div className="i-ph:check-circle text-accent-500" />,
});
}}
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary"
>
Copy File List
</button>
</div>
)}
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
},
);
export const Workbench = memo(
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const isSmallViewport = useViewport(1024);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} 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
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-2 lg:px-6">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.downloadZip();
}}
>
<div className="i-ph:code" />
Download Code
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
<div className="i-ph:git-branch" />
Push to GitHub
</PanelHeaderButton>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</View>
<View
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
>
<Preview />
</View>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview />
</View>
</div>
</div>
</div>
</div>
</div>
<PushToGitHubDialog
isOpen={isPushDialogOpen}
onClose={() => setIsPushDialogOpen(false)}
onPush={async (repoName, username, token, isPrivate) => {
try {
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
return repoUrl;
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub');
throw error; // Rethrow to let PushToGitHubDialog handle the error state
}
}}
/>
</motion.div>
)
);
});
<PushToGitHubDialog
isOpen={isPushDialogOpen}
onClose={() => setIsPushDialogOpen(false)}
onPush={async (repoName, username, token) => {
try {
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
const repoUrl = `https://github.com/${username}/${repoName}`;
if (updateChatMestaData && !metadata?.gitUrl) {
updateChatMestaData({
...(metadata || {}),
gitUrl: repoUrl,
});
}
return repoUrl;
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub');
throw error;
}
}}
/>
</motion.div>
)
);
},
);
// View component for rendering content with motion transitions
interface ViewProps extends HTMLMotionProps<'div'> {

View File

@@ -204,7 +204,10 @@ export async function selectContext(props: {
}
if (!filePaths.includes(fullPath)) {
throw new Error(`File ${path} is not in the list of files above.`);
logger.error(`File ${path} is not in the list of files above.`);
return;
// throw new Error(`File ${path} is not in the list of files above.`);
}
if (currrentFiles.includes(path)) {
@@ -218,6 +221,13 @@ export async function selectContext(props: {
onFinish(resp);
}
const totalFiles = Object.keys(filteredFiles).length;
logger.info(`Total files: ${totalFiles}`);
if (totalFiles == 0) {
throw new Error(`Bolt failed to select files`);
}
return filteredFiles;
// generateText({

View File

@@ -42,6 +42,10 @@ const messageParser = new StreamingMessageParser({
},
},
});
const extractTextContent = (message: Message) =>
Array.isArray(message.content)
? (message.content.find((item) => item.type === 'text')?.text as string) || ''
: message.content;
export function useMessageParser() {
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
@@ -55,9 +59,8 @@ export function useMessageParser() {
}
for (const [index, message] of messages.entries()) {
if (message.role === 'assistant') {
const newParsedContent = messageParser.parse(message.id, message.content);
if (message.role === 'assistant' || message.role === 'user') {
const newParsedContent = messageParser.parse(message.id, extractTextContent(message));
setParsedMessages((prevParsed) => ({
...prevParsed,
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,

View File

@@ -41,7 +41,7 @@ export function useShortcuts(): void {
}
// Debug logging in development only
if (process.env.NODE_ENV === 'development') {
if (import.meta.env.DEV) {
console.log('Key pressed:', {
key: event.key,
code: event.code,

View File

@@ -13,6 +13,12 @@ export default class AnthropicProvider extends BaseProvider {
};
staticModels: ModelInfo[] = [
{
name: 'claude-3-7-sonnet-20250219',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
maxTokenAllowed: 8000,
},
{
name: 'claude-3-5-sonnet-latest',
label: 'Claude 3.5 Sonnet (new)',
@@ -35,6 +41,44 @@ export default class AnthropicProvider extends BaseProvider {
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
];
async getDynamicModels(
apiKeys?: Record<string, string>,
settings?: IProviderSetting,
serverEnv?: Record<string, string>,
): Promise<ModelInfo[]> {
const { apiKey } = this.getProviderBaseUrlAndKey({
apiKeys,
providerSettings: settings,
serverEnv: serverEnv as any,
defaultBaseUrlKey: '',
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
});
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`https://api.anthropic.com/v1/models`, {
headers: {
'x-api-key': `${apiKey}`,
'anthropic-version': '2023-06-01',
},
});
const res = (await response.json()) as any;
const staticModelIds = this.staticModels.map((m) => m.name);
const data = res.data.filter((model: any) => model.type === 'model' && !staticModelIds.includes(model.id));
return data.map((m: any) => ({
name: m.id,
label: `${m.display_name}`,
provider: this.name,
maxTokenAllowed: 32000,
}));
}
getModelInstance: (options: {
model: string;
serverEnv: Env;

View File

@@ -75,7 +75,7 @@ export default class LMStudioProvider extends BaseProvider {
throw new Error('No baseUrl found for LMStudio provider');
}
const isDocker = process.env.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
if (typeof window === 'undefined') {
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;

View File

@@ -27,8 +27,6 @@ export interface OllamaApiResponse {
models: OllamaModel[];
}
export const DEFAULT_NUM_CTX = process?.env?.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
export default class OllamaProvider extends BaseProvider {
name = 'Ollama';
getApiKeyLink = 'https://ollama.com/download';
@@ -41,6 +39,26 @@ export default class OllamaProvider extends BaseProvider {
staticModels: ModelInfo[] = [];
private _convertEnvToRecord(env?: Env): Record<string, string> {
if (!env) {
return {};
}
// Convert Env to a plain object with string values
return Object.entries(env).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>,
);
}
getDefaultNumCtx(serverEnv?: Env): number {
const envRecord = this._convertEnvToRecord(serverEnv);
return envRecord.DEFAULT_NUM_CTX ? parseInt(envRecord.DEFAULT_NUM_CTX, 10) : 32768;
}
async getDynamicModels(
apiKeys?: Record<string, string>,
settings?: IProviderSetting,
@@ -81,6 +99,7 @@ export default class OllamaProvider extends BaseProvider {
maxTokenAllowed: 8000,
}));
}
getModelInstance: (options: {
model: string;
serverEnv?: Env;
@@ -88,10 +107,12 @@ export default class OllamaProvider extends BaseProvider {
providerSettings?: Record<string, IProviderSetting>;
}) => LanguageModelV1 = (options) => {
const { apiKeys, providerSettings, serverEnv, model } = options;
const envRecord = this._convertEnvToRecord(serverEnv);
let { baseUrl } = this.getProviderBaseUrlAndKey({
apiKeys,
providerSettings: providerSettings?.[this.name],
serverEnv: serverEnv as any,
serverEnv: envRecord,
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
defaultApiTokenKey: '',
});
@@ -101,14 +122,14 @@ export default class OllamaProvider extends BaseProvider {
throw new Error('No baseUrl found for OLLAMA provider');
}
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || envRecord.RUNNING_IN_DOCKER === 'true';
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
logger.debug('Ollama Base Url used: ', baseUrl);
const ollamaInstance = ollama(model, {
numCtx: DEFAULT_NUM_CTX,
numCtx: this.getDefaultNumCtx(serverEnv),
}) as LanguageModelV1 & { config: any };
ollamaInstance.config.baseURL = `${baseUrl}/api`;

View File

@@ -5,6 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory';
export interface IChatMetadata {
gitUrl: string;
gitBranch?: string;
netlifySiteId?: string;
}
const logger = createScopedLogger('ChatHistory');

View File

@@ -0,0 +1,7 @@
import type { FileMap } from '~/lib/stores/files';
export interface Snapshot {
chatIndex: string;
files: FileMap;
summary?: string;
}

View File

@@ -1,7 +1,7 @@
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { atom } from 'nanostores';
import type { Message } from 'ai';
import { generateId, type JSONValue, type Message } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { logStore } from '~/lib/stores/logs'; // Import logStore
@@ -15,6 +15,11 @@ import {
createChatFromMessages,
type IChatMetadata,
} from './db';
import type { FileMap } from '~/lib/stores/files';
import type { Snapshot } from './types';
import { webcontainer } from '~/lib/webcontainer';
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
import type { ContextAnnotation } from '~/types/context';
export interface ChatHistoryItem {
id: string;
@@ -37,6 +42,7 @@ export function useChatHistory() {
const { id: mixedId } = useLoaderData<{ id?: string }>();
const [searchParams] = useSearchParams();
const [archivedMessages, setArchivedMessages] = useState<Message[]>([]);
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(false);
const [urlId, setUrlId] = useState<string | undefined>();
@@ -56,14 +62,128 @@ export function useChatHistory() {
if (mixedId) {
getMessages(db, mixedId)
.then((storedMessages) => {
.then(async (storedMessages) => {
if (storedMessages && storedMessages.messages.length > 0) {
const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
const summary = snapshot.summary;
const rewindId = searchParams.get('rewindTo');
const filteredMessages = rewindId
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
: storedMessages.messages;
let startingIdx = -1;
const endingIdx = rewindId
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
: storedMessages.messages.length;
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
startingIdx = snapshotIndex;
}
if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
startingIdx = -1;
}
let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
let archivedMessages: Message[] = [];
if (startingIdx >= 0) {
archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
}
setArchivedMessages(archivedMessages);
if (startingIdx > 0) {
const files = Object.entries(snapshot?.files || {})
.map(([key, value]) => {
if (value?.type !== 'file') {
return null;
}
return {
content: value.content,
path: key,
};
})
.filter((x) => !!x);
const projectCommands = await detectProjectCommands(files);
const commands = createCommandsMessage(projectCommands);
filteredMessages = [
{
id: generateId(),
role: 'user',
content: `Restore project from snapshot
`,
annotations: ['no-store', 'hidden'],
},
{
id: storedMessages.messages[snapshotIndex].id,
role: 'assistant',
content: ` 📦 Chat Restored from snapshot, You can revert this message to load the full chat history
<boltArtifact id="imported-files" title="Project Files Snapshot" type="bundled">
${Object.entries(snapshot?.files || {})
.filter((x) => !x[0].endsWith('lock.json'))
.map(([key, value]) => {
if (value?.type === 'file') {
return `
<boltAction type="file" filePath="${key}">
${value.content}
</boltAction>
`;
} else {
return ``;
}
})
.join('\n')}
</boltArtifact>
`,
annotations: [
'no-store',
...(summary
? [
{
chatId: storedMessages.messages[snapshotIndex].id,
type: 'chatSummary',
summary,
} satisfies ContextAnnotation,
]
: []),
],
},
...(commands !== null
? [
{
id: `${storedMessages.messages[snapshotIndex].id}-2`,
role: 'user' as const,
content: `setup project`,
annotations: ['no-store', 'hidden'],
},
{
...commands,
id: `${storedMessages.messages[snapshotIndex].id}-3`,
annotations: [
'no-store',
...(commands.annotations || []),
...(summary
? [
{
chatId: `${storedMessages.messages[snapshotIndex].id}-3`,
type: 'chatSummary',
summary,
} satisfies ContextAnnotation,
]
: []),
],
},
]
: []),
...filteredMessages,
];
restoreSnapshot(mixedId);
}
setInitialMessages(filteredMessages);
setUrlId(storedMessages.urlId);
description.set(storedMessages.description);
chatId.set(storedMessages.id);
@@ -75,10 +195,64 @@ export function useChatHistory() {
setReady(true);
})
.catch((error) => {
console.error(error);
logStore.logError('Failed to load chat messages', error);
toast.error(error.message);
});
}
}, [mixedId]);
const takeSnapshot = useCallback(
async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
const id = _chatId || chatId;
if (!id) {
return;
}
const snapshot: Snapshot = {
chatIndex: chatIdx,
files,
summary: chatSummary,
};
localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
},
[chatId],
);
const restoreSnapshot = useCallback(async (id: string) => {
const snapshotStr = localStorage.getItem(`snapshot:${id}`);
const container = await webcontainer;
// if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
if (!snapshot?.files) {
return;
}
Object.entries(snapshot.files).forEach(async ([key, value]) => {
if (key.startsWith(container.workdir)) {
key = key.replace(container.workdir, '');
}
if (value?.type === 'folder') {
await container.fs.mkdir(key, { recursive: true });
}
});
Object.entries(snapshot.files).forEach(async ([key, value]) => {
if (value?.type === 'file') {
if (key.startsWith(container.workdir)) {
key = key.replace(container.workdir, '');
}
await container.fs.writeFile(key, value.content, { encoding: value.isBinary ? undefined : 'utf8' });
} else {
}
});
// workbenchStore.files.setKey(snapshot?.files)
}, []);
return {
@@ -105,14 +279,34 @@ export function useChatHistory() {
}
const { firstArtifact } = workbenchStore;
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
let _urlId = urlId;
if (!urlId && firstArtifact?.id) {
const urlId = await getUrlId(db, firstArtifact.id);
_urlId = urlId;
navigateChat(urlId);
setUrlId(urlId);
}
let chatSummary: string | undefined = undefined;
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'assistant') {
const annotations = lastMessage.annotations as JSONValue[];
const filteredAnnotations = (annotations?.filter(
(annotation: JSONValue) =>
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
) || []) as { type: string; value: any } & { [key: string]: any }[];
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
}
}
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
if (!description.get() && firstArtifact?.title) {
description.set(firstArtifact?.title);
}
@@ -127,7 +321,15 @@ export function useChatHistory() {
}
}
await setMessages(db, chatId.get() as string, messages, urlId, description.get(), undefined, chatMetadata.get());
await setMessages(
db,
chatId.get() as string,
[...archivedMessages, ...messages],
urlId,
description.get(),
undefined,
chatMetadata.get(),
);
},
duplicateCurrentChat: async (listItemId: string) => {
if (!db || (!mixedId && !listItemId)) {

View File

@@ -1,7 +1,7 @@
import type { WebContainer } from '@webcontainer/api';
import { path } from '~/utils/path';
import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction } from '~/types/actions';
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
@@ -70,6 +70,7 @@ export class ActionRunner {
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string };
constructor(
webcontainerPromise: Promise<WebContainer>,
@@ -156,6 +157,13 @@ export class ActionRunner {
await this.#runFileAction(action);
break;
}
case 'build': {
const buildOutput = await this.#runBuildAction(action);
// Store build output for deployment
this.buildOutput = buildOutput;
break;
}
case 'start': {
// making the start app non blocking
@@ -276,9 +284,9 @@ export class ActionRunner {
}
const webcontainer = await this.#webcontainer;
const relativePath = path.relative(webcontainer.workdir, action.filePath);
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
let folder = path.dirname(relativePath);
let folder = nodePath.dirname(relativePath);
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
@@ -299,9 +307,74 @@ export class ActionRunner {
logger.error('Failed to write file\n\n', error);
}
}
#updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get();
this.actions.setKey(id, { ...actions[id], ...newState });
}
async getFileHistory(filePath: string): Promise<FileHistory | null> {
try {
const webcontainer = await this.#webcontainer;
const historyPath = this.#getHistoryPath(filePath);
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
logger.error('Failed to get file history:', error);
return null;
}
}
async saveFileHistory(filePath: string, history: FileHistory) {
// const webcontainer = await this.#webcontainer;
const historyPath = this.#getHistoryPath(filePath);
await this.#runFileAction({
type: 'file',
filePath: historyPath,
content: JSON.stringify(history),
changeSource: 'auto-save',
} as any);
}
#getHistoryPath(filePath: string) {
return nodePath.join('.history', filePath);
}
async #runBuildAction(action: ActionState) {
if (action.type !== 'build') {
unreachable('Expected build action');
}
const webcontainer = await this.#webcontainer;
// Create a new terminal specifically for the build
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
let output = '';
buildProcess.output.pipeTo(
new WritableStream({
write(data) {
output += data;
},
}),
);
const exitCode = await buildProcess.exit;
if (exitCode !== 0) {
throw new ActionCommandError('Build Failed', output || 'No Output Available');
}
// Get the build output directory path
const buildDir = nodePath.join(webcontainer.workdir, 'dist');
return {
path: buildDir,
exitCode,
output,
};
}
}

View File

@@ -75,6 +75,29 @@ export class FilesStore {
getFileModifications() {
return computeFileModifications(this.files.get(), this.#modifiedFiles);
}
getModifiedFiles() {
let modifiedFiles: { [path: string]: File } | undefined = undefined;
for (const [filePath, originalContent] of this.#modifiedFiles) {
const file = this.files.get()[filePath];
if (file?.type !== 'file') {
continue;
}
if (file.content === originalContent) {
continue;
}
if (!modifiedFiles) {
modifiedFiles = {};
}
modifiedFiles[filePath] = file;
}
return modifiedFiles;
}
resetFileModifications() {
this.#modifiedFiles.clear();
@@ -92,7 +115,7 @@ export class FilesStore {
const oldContent = this.getFile(filePath)?.content;
if (!oldContent) {
if (!oldContent && oldContent !== '') {
unreachable('Expected content to be defined');
}

63
app/lib/stores/netlify.ts Normal file
View File

@@ -0,0 +1,63 @@
import { atom } from 'nanostores';
import type { NetlifyConnection } from '~/types/netlify';
import { logStore } from './logs';
import { toast } from 'react-toastify';
// Initialize with stored connection or defaults
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
const initialConnection: NetlifyConnection = storedConnection
? JSON.parse(storedConnection)
: {
user: null,
token: '',
stats: undefined,
};
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
export const isConnecting = atom<boolean>(false);
export const isFetchingStats = atom<boolean>(false);
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
const currentState = netlifyConnection.get();
const newState = { ...currentState, ...updates };
netlifyConnection.set(newState);
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('netlify_connection', JSON.stringify(newState));
}
};
export async function fetchNetlifyStats(token: string) {
try {
isFetchingStats.set(true);
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!sitesResponse.ok) {
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
}
const sites = (await sitesResponse.json()) as any;
const currentState = netlifyConnection.get();
updateNetlifyConnection({
...currentState,
stats: {
sites,
totalSites: sites.length,
},
});
} catch (error) {
console.error('Netlify API Error:', error);
logStore.logError('Failed to fetch Netlify stats', { error });
toast.error('Failed to fetch Netlify statistics');
} finally {
isFetchingStats.set(false);
}
}

View File

@@ -235,7 +235,7 @@ const getInitialTabConfiguration = (): TabWindowConfig => {
}
};
console.log('Initial tab configuration:', getInitialTabConfiguration());
// console.log('Initial tab configuration:', getInitialTabConfiguration());
export const tabConfigurationStore = map<TabWindowConfig>(getInitialTabConfiguration());

View File

@@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const streamingState = atom<boolean>(false);

View File

@@ -19,7 +19,6 @@ import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions';
// Destructure saveAs from the CommonJS module
const { saveAs } = fileSaver;
export interface ArtifactState {
@@ -34,7 +33,7 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type Artifacts = MapStore<Record<string, ArtifactState>>;
export type WorkbenchViewType = 'code' | 'preview';
export type WorkbenchViewType = 'code' | 'diff' | 'preview';
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
@@ -239,6 +238,9 @@ export class WorkbenchStore {
getFileModifcations() {
return this.#filesStore.getFileModifications();
}
getModifiedFiles() {
return this.#filesStore.getModifiedFiles();
}
resetAllFileModifications() {
this.#filesStore.resetFileModifications();
@@ -437,13 +439,7 @@ export class WorkbenchStore {
return syncedFiles;
}
async pushToGitHub(
repoName: string,
commitMessage?: string,
githubUsername?: string,
ghToken?: string,
isPrivate: boolean = false,
) {
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
try {
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
@@ -467,7 +463,7 @@ export class WorkbenchStore {
// Repository doesn't exist, so create a new one
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
name: repoName,
private: isPrivate,
private: false,
auto_init: true,
});
repo = newRepo;
@@ -545,7 +541,7 @@ export class WorkbenchStore {
sha: newCommit.sha,
});
return repo.html_url; // Return the URL instead of showing alert
alert(`Repository created and code pushed: ${repo.html_url}`);
} catch (error) {
console.error('Error pushing to GitHub:', error);
throw error; // Rethrow the error for further handling

View File

@@ -8,6 +8,7 @@ import { createHead } from 'remix-island';
import { useEffect } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ClientOnly } from 'remix-utils/client-only';
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
import globalStyles from './styles/index.scss?url';
@@ -72,11 +73,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
}, [theme]);
return (
<DndProvider backend={HTML5Backend}>
{children}
<>
<ClientOnly>{() => <DndProvider backend={HTML5Backend}>{children}</DndProvider>}</ClientOnly>
<ScrollRestoration />
<Scripts />
</DndProvider>
</>
);
}

View File

@@ -1,16 +1,41 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { LLMManager } from '~/lib/modules/llm/manager';
import { getApiKeysFromCookie } from '~/lib/api/cookies';
export const loader: LoaderFunction = async ({ context, request }) => {
const url = new URL(request.url);
const provider = url.searchParams.get('provider');
if (!provider || !providerBaseUrlEnvKeys[provider].apiTokenKey) {
if (!provider) {
return Response.json({ isSet: false });
}
const envVarName = providerBaseUrlEnvKeys[provider].apiTokenKey;
const isSet = !!(process.env[envVarName] || (context?.cloudflare?.env as Record<string, any>)?.[envVarName]);
const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any);
const providerInstance = llmManager.getProvider(provider);
if (!providerInstance || !providerInstance.config.apiTokenKey) {
return Response.json({ isSet: false });
}
const envVarName = providerInstance.config.apiTokenKey;
// Get API keys from cookie
const cookieHeader = request.headers.get('Cookie');
const apiKeys = getApiKeysFromCookie(cookieHeader);
/*
* Check API key in order of precedence:
* 1. Client-side API keys (from cookies)
* 2. Server environment variables (from Cloudflare env)
* 3. Process environment variables (from .env.local)
* 4. LLMManager environment variables
*/
const isSet = !!(
apiKeys?.[provider] ||
(context?.cloudflare?.env as Record<string, any>)?.[envVarName] ||
process.env[envVarName] ||
llmManager.env[envVarName]
);
return Response.json({ isSet });
};

237
app/routes/api.deploy.ts Normal file
View File

@@ -0,0 +1,237 @@
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
import type { NetlifySiteInfo } from '~/types/netlify';
interface DeployRequestBody {
siteId?: string;
files: Record<string, string>;
chatId: string;
}
async function sha1(message: string) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
export async function action({ request }: ActionFunctionArgs) {
try {
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
if (!token) {
return json({ error: 'Not connected to Netlify' }, { status: 401 });
}
let targetSiteId = siteId;
let siteInfo: NetlifySiteInfo | undefined;
// If no siteId provided, create a new site
if (!targetSiteId) {
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 });
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
} else {
// Get existing site info
if (targetSiteId) {
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (siteResponse.ok) {
const existingSite = (await siteResponse.json()) as any;
siteInfo = {
id: existingSite.id,
name: existingSite.name,
url: existingSite.url,
chatId,
};
} else {
targetSiteId = undefined;
}
}
// If no siteId provided or site doesn't exist, create a new site
if (!targetSiteId) {
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 });
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
}
}
// Create file digests
const fileDigests: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
const hash = await sha1(content);
fileDigests[normalizedPath] = hash;
}
// Create a new deploy with digests
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: true,
skip_processing: false,
draft: false, // Change this to false for production deployments
function_schedules: [],
required: Object.keys(fileDigests), // Add this line
framework: null,
}),
});
if (!deployResponse.ok) {
return json({ error: 'Failed to create deployment' }, { status: 400 });
}
const deploy = (await deployResponse.json()) as any;
let retryCount = 0;
const maxRetries = 60;
// Poll until deploy is ready for file uploads
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const status = (await statusResponse.json()) as any;
if (status.state === 'prepared' || status.state === 'uploaded') {
// Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
let uploadSuccess = false;
let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) {
try {
const uploadResponse = await fetch(
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: content,
},
);
uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) {
console.error('Upload failed:', await uploadResponse.text());
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} catch (error) {
console.error('Upload error:', error);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!uploadSuccess) {
return json({ error: `Failed to upload file ${filePath}` }, { status: 500 });
}
}
}
if (status.state === 'ready') {
// Only return after files are uploaded
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
return json({
success: true,
deploy: {
id: status.id,
state: status.state,
url: status.ssl_url || status.url,
},
site: siteInfo,
});
}
}
if (status.state === 'error') {
return json({ error: status.error_message || 'Deploy preparation failed' }, { status: 500 });
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxRetries) {
return json({ error: 'Deploy preparation timed out' }, { status: 500 });
}
// Make sure we're returning the deploy ID and site info
return json({
success: true,
deploy: {
id: deploy.id,
state: deploy.state,
},
site: siteInfo,
});
} catch (error) {
console.error('Deploy error:', error);
return json({ error: 'Deployment failed' }, { status: 500 });
}
}

View File

@@ -3,11 +3,14 @@ import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent';
import type { ProviderInfo } from '~/types/model';
import { getApiKeysFromCookie, getProviderSettingsFromCookie } from '~/lib/api/cookies';
import { createScopedLogger } from '~/utils/logger';
export async function action(args: ActionFunctionArgs) {
return enhancerAction(args);
}
const logger = createScopedLogger('api.enhancher');
async function enhancerAction({ context, request }: ActionFunctionArgs) {
const { message, model, provider } = await request.json<{
message: string;
@@ -77,15 +80,43 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
env: context.cloudflare?.env as any,
apiKeys,
providerSettings,
options: {
system:
'You are a senior software principal architect, you should help the user analyse the user query and enrich it with the necessary context and constraints to make it more specific, actionable, and effective. You should also ensure that the prompt is self-contained and uses professional language. Your response should ONLY contain the enhanced prompt text. Do not include any explanations, metadata, or wrapper tags.',
/*
* onError: (event) => {
* throw new Response(null, {
* status: 500,
* statusText: 'Internal Server Error',
* });
* }
*/
},
});
// Handle streaming errors in a non-blocking way
(async () => {
try {
for await (const part of result.fullStream) {
if (part.type === 'error') {
const error: any = part.error;
logger.error('Streaming error:', error);
break;
}
}
} catch (error) {
logger.error('Error processing stream:', error);
}
})();
// Return the text stream directly since it's already text data
return new Response(result.textStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache',
'Text-Encoding': 'chunked',
},
});
} catch (error: unknown) {

View File

@@ -1,6 +1,47 @@
import { json } from '@remix-run/cloudflare';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
// Allowed headers to forward to the target server
const ALLOW_HEADERS = [
'accept-encoding',
'accept-language',
'accept',
'access-control-allow-origin',
'authorization',
'cache-control',
'connection',
'content-length',
'content-type',
'dnt',
'pragma',
'range',
'referer',
'user-agent',
'x-authorization',
'x-http-method-override',
'x-requested-with',
];
// Headers to expose from the target server's response
const EXPOSE_HEADERS = [
'accept-ranges',
'age',
'cache-control',
'content-length',
'content-language',
'content-type',
'date',
'etag',
'expires',
'last-modified',
'pragma',
'server',
'transfer-encoding',
'vary',
'x-github-request-id',
'x-redirected-url',
];
// Handle all HTTP methods
export async function action({ request, params }: ActionFunctionArgs) {
return handleProxyRequest(request, params['*']);
@@ -16,50 +57,121 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
}
const url = new URL(request.url);
// Reconstruct the target URL
const targetURL = `https://${path}${url.search}`;
// Forward the request to the target URL
const response = await fetch(targetURL, {
method: request.method,
headers: {
...Object.fromEntries(request.headers),
// Override host header with the target host
host: new URL(targetURL).host,
},
body: ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer(),
});
// Create response with CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
// Handle preflight requests
// Handle CORS preflight request
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: corsHeaders,
status: 204,
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': ALLOW_HEADERS.join(', '),
'Access-Control-Expose-Headers': EXPOSE_HEADERS.join(', '),
'Access-Control-Max-Age': '86400',
},
});
}
// Forward the response with CORS headers
const responseHeaders = new Headers(response.headers);
Object.entries(corsHeaders).forEach(([key, value]) => {
responseHeaders.set(key, value);
});
// Extract domain and remaining path
const parts = path.match(/([^\/]+)\/?(.*)/);
if (!parts) {
return json({ error: 'Invalid path format' }, { status: 400 });
}
const domain = parts[1];
const remainingPath = parts[2] || '';
// Reconstruct the target URL with query parameters
const url = new URL(request.url);
const targetURL = `https://${domain}/${remainingPath}${url.search}`;
console.log('Target URL:', targetURL);
// Filter and prepare headers
const headers = new Headers();
// Only forward allowed headers
for (const header of ALLOW_HEADERS) {
if (request.headers.has(header)) {
headers.set(header, request.headers.get(header)!);
}
}
// Set the host header
headers.set('Host', domain);
// Set Git user agent if not already present
if (!headers.has('user-agent') || !headers.get('user-agent')?.startsWith('git/')) {
headers.set('User-Agent', 'git/@isomorphic-git/cors-proxy');
}
console.log('Request headers:', Object.fromEntries(headers.entries()));
// Prepare fetch options
const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'follow',
};
// Add body for non-GET/HEAD requests
if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
/*
* Note: duplex property is removed to ensure TypeScript compatibility
* across different environments and versions
*/
}
// Forward the request to the target URL
const response = await fetch(targetURL, fetchOptions);
console.log('Response status:', response.status);
// Create response headers
const responseHeaders = new Headers();
// Add CORS headers
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', ALLOW_HEADERS.join(', '));
responseHeaders.set('Access-Control-Expose-Headers', EXPOSE_HEADERS.join(', '));
// Copy exposed headers from the target response
for (const header of EXPOSE_HEADERS) {
// Skip content-length as we'll use the original response's content-length
if (header === 'content-length') {
continue;
}
if (response.headers.has(header)) {
responseHeaders.set(header, response.headers.get(header)!);
}
}
// If the response was redirected, add the x-redirected-url header
if (response.redirected) {
responseHeaders.set('x-redirected-url', response.url);
}
console.log('Response headers:', Object.fromEntries(responseHeaders.entries()));
// Return the response with the target's body stream piped directly
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error('Git proxy error:', error);
return json({ error: 'Proxy error' }, { status: 500 });
console.error('Proxy error:', error);
return json(
{
error: 'Proxy error',
message: error instanceof Error ? error.message : 'Unknown error',
url: path ? `https://${path}` : 'Invalid URL',
},
{ status: 500 },
);
}
}

View File

@@ -1,18 +1,8 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
// Return a simple 200 OK response with some basic health information
return new Response(
JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
);
return json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
};

View File

@@ -1,6 +1,5 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { execSync } from 'child_process';
// These are injected by Vite at build time
declare const __APP_VERSION: string;
@@ -11,34 +10,24 @@ declare const __PKG_DEPENDENCIES: Record<string, string>;
declare const __PKG_DEV_DEPENDENCIES: Record<string, string>;
declare const __PKG_PEER_DEPENDENCIES: Record<string, string>;
declare const __PKG_OPTIONAL_DEPENDENCIES: Record<string, string>;
declare const __COMMIT_HASH: string;
declare const __GIT_BRANCH: string;
declare const __GIT_COMMIT_TIME: string;
declare const __GIT_AUTHOR: string;
declare const __GIT_EMAIL: string;
declare const __GIT_REMOTE_URL: string;
declare const __GIT_REPO_NAME: string;
const getGitInfo = () => {
try {
return {
commitHash: execSync('git rev-parse --short HEAD').toString().trim(),
branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
commitTime: execSync('git log -1 --format=%cd').toString().trim(),
author: execSync('git log -1 --format=%an').toString().trim(),
email: execSync('git log -1 --format=%ae').toString().trim(),
remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
repoName: execSync('git config --get remote.origin.url')
.toString()
.trim()
.replace(/^.*github.com[:/]/, '')
.replace(/\.git$/, ''),
};
} catch (error) {
console.error('Failed to get git info:', error);
return {
commitHash: 'unknown',
branch: 'unknown',
commitTime: 'unknown',
author: 'unknown',
email: 'unknown',
remoteUrl: 'unknown',
repoName: 'unknown',
};
}
return {
commitHash: __COMMIT_HASH || 'unknown',
branch: __GIT_BRANCH || 'unknown',
commitTime: __GIT_COMMIT_TIME || 'unknown',
author: __GIT_AUTHOR || 'unknown',
email: __GIT_EMAIL || 'unknown',
remoteUrl: __GIT_REMOTE_URL || 'unknown',
repoName: __GIT_REPO_NAME || 'unknown',
};
};
const formatDependencies = (
@@ -60,11 +49,11 @@ const getAppResponse = () => {
version: __APP_VERSION || '0.1.0',
description: __PKG_DESCRIPTION || 'A DIY LLM interface',
license: __PKG_LICENSE || 'MIT',
environment: process.env.NODE_ENV || 'development',
environment: 'cloudflare',
gitInfo,
timestamp: new Date().toISOString(),
runtimeInfo: {
nodeVersion: process.version || 'unknown',
nodeVersion: 'cloudflare',
},
dependencies: {
production: formatDependencies(__PKG_DEPENDENCIES, 'production'),

View File

@@ -1,138 +1,48 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { execSync } from 'child_process';
import { json, type LoaderFunction } from '@remix-run/cloudflare';
interface GitHubRepoInfo {
name: string;
full_name: string;
default_branch: string;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
parent?: {
full_name: string;
default_branch: string;
stargazers_count: number;
forks_count: number;
interface GitInfo {
local: {
commitHash: string;
branch: string;
commitTime: string;
author: string;
email: string;
remoteUrl: string;
repoName: string;
};
github?: {
currentRepo?: {
fullName: string;
defaultBranch: string;
stars: number;
forks: number;
openIssues?: number;
};
};
isForked?: boolean;
}
const getLocalGitInfo = () => {
try {
return {
commitHash: execSync('git rev-parse HEAD').toString().trim(),
branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
commitTime: execSync('git log -1 --format=%cd').toString().trim(),
author: execSync('git log -1 --format=%an').toString().trim(),
email: execSync('git log -1 --format=%ae').toString().trim(),
remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
repoName: execSync('git config --get remote.origin.url')
.toString()
.trim()
.replace(/^.*github.com[:/]/, '')
.replace(/\.git$/, ''),
};
} catch (error) {
console.error('Failed to get local git info:', error);
return null;
}
};
// These values will be replaced at build time
declare const __COMMIT_HASH: string;
declare const __GIT_BRANCH: string;
declare const __GIT_COMMIT_TIME: string;
declare const __GIT_AUTHOR: string;
declare const __GIT_EMAIL: string;
declare const __GIT_REMOTE_URL: string;
declare const __GIT_REPO_NAME: string;
const getGitHubInfo = async (repoFullName: string) => {
try {
// Add GitHub token if available
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
};
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
headers.Authorization = `token ${githubToken}`;
}
console.log('Fetching GitHub info for:', repoFullName); // Debug log
const response = await fetch(`https://api.github.com/repos/${repoFullName}`, {
headers,
});
if (!response.ok) {
console.error('GitHub API error:', {
status: response.status,
statusText: response.statusText,
repoFullName,
});
// If we get a 404, try the main repo as fallback
if (response.status === 404 && repoFullName !== 'stackblitz-labs/bolt.diy') {
return getGitHubInfo('stackblitz-labs/bolt.diy');
}
throw new Error(`GitHub API error: ${response.statusText}`);
}
const data = await response.json();
console.log('GitHub API response:', data); // Debug log
return data as GitHubRepoInfo;
} catch (error) {
console.error('Failed to get GitHub info:', error);
return null;
}
};
export const loader: LoaderFunction = async ({ request: _request }) => {
const localInfo = getLocalGitInfo();
console.log('Local git info:', localInfo); // Debug log
// If we have local info, try to get GitHub info for both our fork and upstream
let githubInfo = null;
if (localInfo?.repoName) {
githubInfo = await getGitHubInfo(localInfo.repoName);
}
// If no local info or GitHub info, try the main repo
if (!githubInfo) {
githubInfo = await getGitHubInfo('stackblitz-labs/bolt.diy');
}
const response = {
local: localInfo || {
commitHash: 'unknown',
branch: 'unknown',
commitTime: 'unknown',
author: 'unknown',
email: 'unknown',
remoteUrl: 'unknown',
repoName: 'unknown',
export const loader: LoaderFunction = async () => {
const gitInfo: GitInfo = {
local: {
commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
branch: typeof __GIT_BRANCH !== 'undefined' ? __GIT_BRANCH : 'main',
commitTime: typeof __GIT_COMMIT_TIME !== 'undefined' ? __GIT_COMMIT_TIME : new Date().toISOString(),
author: typeof __GIT_AUTHOR !== 'undefined' ? __GIT_AUTHOR : 'development',
email: typeof __GIT_EMAIL !== 'undefined' ? __GIT_EMAIL : 'development@local',
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
},
github: githubInfo
? {
currentRepo: {
fullName: githubInfo.full_name,
defaultBranch: githubInfo.default_branch,
stars: githubInfo.stargazers_count,
forks: githubInfo.forks_count,
openIssues: githubInfo.open_issues_count,
},
upstream: githubInfo.parent
? {
fullName: githubInfo.parent.full_name,
defaultBranch: githubInfo.parent.default_branch,
stars: githubInfo.parent.stargazers_count,
forks: githubInfo.parent.forks_count,
}
: null,
}
: null,
isForked: Boolean(githubInfo?.parent),
timestamp: new Date().toISOString(),
};
console.log('Final response:', response);
// Debug log
return json(response);
return json(gitInfo);
};

View File

@@ -1,573 +1,21 @@
import { json } from '@remix-run/node';
import type { ActionFunction } from '@remix-run/node';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface UpdateRequestBody {
branch: string;
autoUpdate?: boolean;
}
interface UpdateProgress {
stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
message: string;
progress?: number;
error?: string;
details?: {
changedFiles?: string[];
additions?: number;
deletions?: number;
commitMessages?: string[];
totalSize?: string;
currentCommit?: string;
remoteCommit?: string;
updateReady?: boolean;
changelog?: string;
compareUrl?: string;
};
}
import { json, type ActionFunction } from '@remix-run/cloudflare';
export const action: ActionFunction = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = await request.json();
if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') {
return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 });
}
const { branch, autoUpdate = false } = body as UpdateRequestBody;
// Create a ReadableStream to send progress updates
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendProgress = (update: UpdateProgress) => {
controller.enqueue(encoder.encode(JSON.stringify(update) + '\n'));
};
try {
// Initial check stage
sendProgress({
stage: 'fetch',
message: 'Checking repository status...',
progress: 0,
});
// Check if remote exists
let defaultBranch = branch || 'main'; // Make branch mutable
try {
await execAsync('git remote get-url upstream');
sendProgress({
stage: 'fetch',
message: 'Repository remote verified',
progress: 10,
});
} catch {
throw new Error(
'No upstream repository found. Please set up the upstream repository first by running:\ngit remote add upstream https://github.com/stackblitz-labs/bolt.diy.git',
);
}
// Get default branch if not specified
if (!branch) {
sendProgress({
stage: 'fetch',
message: 'Detecting default branch...',
progress: 20,
});
try {
const { stdout } = await execAsync('git remote show upstream | grep "HEAD branch" | cut -d" " -f5');
defaultBranch = stdout.trim() || 'main';
sendProgress({
stage: 'fetch',
message: `Using branch: ${defaultBranch}`,
progress: 30,
});
} catch {
defaultBranch = 'main'; // Fallback to main if we can't detect
sendProgress({
stage: 'fetch',
message: 'Using default branch: main',
progress: 30,
});
}
}
// Fetch stage
sendProgress({
stage: 'fetch',
message: 'Fetching latest changes...',
progress: 40,
});
// Fetch all remotes
await execAsync('git fetch --all');
sendProgress({
stage: 'fetch',
message: 'Remote changes fetched',
progress: 50,
});
// Check if remote branch exists
try {
await execAsync(`git rev-parse --verify upstream/${defaultBranch}`);
sendProgress({
stage: 'fetch',
message: 'Remote branch verified',
progress: 60,
});
} catch {
throw new Error(
`Remote branch 'upstream/${defaultBranch}' not found. Please ensure the upstream repository is properly configured.`,
);
}
// Get current commit hash and remote commit hash
sendProgress({
stage: 'fetch',
message: 'Comparing versions...',
progress: 70,
});
const { stdout: currentCommit } = await execAsync('git rev-parse HEAD');
const { stdout: remoteCommit } = await execAsync(`git rev-parse upstream/${defaultBranch}`);
// If we're on the same commit, no update is available
if (currentCommit.trim() === remoteCommit.trim()) {
sendProgress({
stage: 'complete',
message: 'No updates available. You are on the latest version.',
progress: 100,
details: {
currentCommit: currentCommit.trim().substring(0, 7),
remoteCommit: remoteCommit.trim().substring(0, 7),
},
});
return;
}
sendProgress({
stage: 'fetch',
message: 'Analyzing changes...',
progress: 80,
});
// Initialize variables
let changedFiles: string[] = [];
let commitMessages: string[] = [];
let stats: RegExpMatchArray | null = null;
let totalSizeInBytes = 0;
// Format size for display
const formatSize = (bytes: number) => {
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(2))} ${sizes[i]}`;
};
// Get list of changed files and their sizes
try {
const { stdout: diffOutput } = await execAsync(
`git diff --name-status ${currentCommit.trim()}..${remoteCommit.trim()}`,
);
const files = diffOutput.split('\n').filter(Boolean);
if (files.length === 0) {
sendProgress({
stage: 'complete',
message: `No file changes detected between your version and upstream/${defaultBranch}. You might be on a different branch.`,
progress: 100,
details: {
currentCommit: currentCommit.trim().substring(0, 7),
remoteCommit: remoteCommit.trim().substring(0, 7),
},
});
return;
}
sendProgress({
stage: 'fetch',
message: `Found ${files.length} changed files, calculating sizes...`,
progress: 90,
});
// Get size information for each changed file
for (const line of files) {
const [status, file] = line.split('\t');
if (status !== 'D') {
// Skip deleted files
try {
const { stdout: sizeOutput } = await execAsync(`git cat-file -s ${remoteCommit.trim()}:${file}`);
const size = parseInt(sizeOutput) || 0;
totalSizeInBytes += size;
} catch {
console.debug(`Could not get size for file: ${file}`);
}
}
}
changedFiles = files.map((line) => {
const [status, file] = line.split('\t');
return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`;
});
} catch (err) {
console.debug('Failed to get changed files:', err);
throw new Error(`Failed to compare changes with upstream/${defaultBranch}. Are you on the correct branch?`);
}
// Get commit messages between current and remote
try {
const { stdout: logOutput } = await execAsync(
`git log --pretty=format:"%h|%s|%aI" ${currentCommit.trim()}..${remoteCommit.trim()}`,
);
// Parse and group commits by type
const commits = logOutput
.split('\n')
.filter(Boolean)
.map((line) => {
const [hash, subject, timestamp] = line.split('|');
let type = 'other';
let message = subject;
if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
type = 'feature';
message = subject.replace(/^feat(?:ure)?:/, '').trim();
} else if (subject.startsWith('fix:')) {
type = 'fix';
message = subject.replace(/^fix:/, '').trim();
} else if (subject.startsWith('docs:')) {
type = 'docs';
message = subject.replace(/^docs:/, '').trim();
} else if (subject.startsWith('style:')) {
type = 'style';
message = subject.replace(/^style:/, '').trim();
} else if (subject.startsWith('refactor:')) {
type = 'refactor';
message = subject.replace(/^refactor:/, '').trim();
} else if (subject.startsWith('perf:')) {
type = 'perf';
message = subject.replace(/^perf:/, '').trim();
} else if (subject.startsWith('test:')) {
type = 'test';
message = subject.replace(/^test:/, '').trim();
} else if (subject.startsWith('build:')) {
type = 'build';
message = subject.replace(/^build:/, '').trim();
} else if (subject.startsWith('ci:')) {
type = 'ci';
message = subject.replace(/^ci:/, '').trim();
}
return {
hash,
type,
message,
timestamp: new Date(timestamp),
};
});
// Group commits by type
const groupedCommits = commits.reduce(
(acc, commit) => {
if (!acc[commit.type]) {
acc[commit.type] = [];
}
acc[commit.type].push(commit);
return acc;
},
{} as Record<string, typeof commits>,
);
// Format commit messages with emojis and timestamps
const formattedMessages = Object.entries(groupedCommits).map(([type, commits]) => {
const emoji = {
feature: '✨',
fix: '🐛',
docs: '📚',
style: '💎',
refactor: '♻️',
perf: '⚡',
test: '🧪',
build: '🛠️',
ci: '⚙️',
other: '🔍',
}[type];
const title = {
feature: 'Features',
fix: 'Bug Fixes',
docs: 'Documentation',
style: 'Styles',
refactor: 'Code Refactoring',
perf: 'Performance',
test: 'Tests',
build: 'Build',
ci: 'CI',
other: 'Other Changes',
}[type];
return `### ${emoji} ${title}\n\n${commits
.map((c) => `* ${c.message} (${c.hash.substring(0, 7)}) - ${c.timestamp.toLocaleString()}`)
.join('\n')}`;
});
commitMessages = formattedMessages;
} catch {
// Handle silently - empty commitMessages array will be used
}
// Get diff stats using the specific commits
try {
const { stdout: diffStats } = await execAsync(
`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`,
);
stats = diffStats.match(
/(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/,
);
} catch {
// Handle silently - null stats will be used
}
// If we somehow still have no changes detected
if (!stats && changedFiles.length === 0) {
sendProgress({
stage: 'complete',
message: `No changes detected between your version and upstream/${defaultBranch}. This might be unexpected - please check your git status.`,
progress: 100,
});
return;
}
// Fetch changelog
sendProgress({
stage: 'fetch',
message: 'Fetching changelog...',
progress: 95,
});
const changelog = await fetchChangelog(currentCommit.trim(), remoteCommit.trim());
// We have changes, send the details
sendProgress({
stage: 'fetch',
message: `Changes detected on upstream/${defaultBranch}`,
progress: 100,
details: {
changedFiles,
additions: stats?.[2] ? parseInt(stats[2]) : 0,
deletions: stats?.[3] ? parseInt(stats[3]) : 0,
commitMessages,
totalSize: formatSize(totalSizeInBytes),
currentCommit: currentCommit.trim().substring(0, 7),
remoteCommit: remoteCommit.trim().substring(0, 7),
updateReady: true,
changelog,
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
},
});
// Only proceed with update if autoUpdate is true
if (!autoUpdate) {
sendProgress({
stage: 'complete',
message: 'Update is ready to be applied. Click "Update Now" to proceed.',
progress: 100,
details: {
changedFiles,
additions: stats?.[2] ? parseInt(stats[2]) : 0,
deletions: stats?.[3] ? parseInt(stats[3]) : 0,
commitMessages,
totalSize: formatSize(totalSizeInBytes),
currentCommit: currentCommit.trim().substring(0, 7),
remoteCommit: remoteCommit.trim().substring(0, 7),
updateReady: true,
changelog,
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
},
});
return;
}
// Pull stage
sendProgress({
stage: 'pull',
message: `Pulling changes from upstream/${defaultBranch}...`,
progress: 0,
});
await execAsync(`git pull upstream ${defaultBranch}`);
sendProgress({
stage: 'pull',
message: 'Changes pulled successfully',
progress: 100,
});
// Install stage
sendProgress({
stage: 'install',
message: 'Installing dependencies...',
progress: 0,
});
await execAsync('pnpm install');
sendProgress({
stage: 'install',
message: 'Dependencies installed successfully',
progress: 100,
});
// Build stage
sendProgress({
stage: 'build',
message: 'Building application...',
progress: 0,
});
await execAsync('pnpm build');
sendProgress({
stage: 'build',
message: 'Build completed successfully',
progress: 100,
});
// Complete
sendProgress({
stage: 'complete',
message: 'Update completed successfully! Click Restart to apply changes.',
progress: 100,
});
} catch (err) {
sendProgress({
stage: 'complete',
message: 'Update failed',
error: err instanceof Error ? err.message : 'Unknown error occurred',
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (err) {
console.error('Update preparation failed:', err);
return json(
{
success: false,
error: err instanceof Error ? err.message : 'Unknown error occurred while preparing update',
},
{ status: 500 },
);
}
return json(
{
error: 'Updates must be performed manually in a server environment',
instructions: [
'1. Navigate to the project directory',
'2. Run: git fetch upstream',
'3. Run: git pull upstream main',
'4. Run: pnpm install',
'5. Run: pnpm run build',
],
},
{ status: 400 },
);
};
// Add this function to fetch the changelog
async function fetchChangelog(currentCommit: string, remoteCommit: string): Promise<string> {
try {
// First try to get the changelog.md content
const { stdout: changelogContent } = await execAsync('git show upstream/main:changelog.md');
// If we have a changelog, return it
if (changelogContent) {
return changelogContent;
}
// If no changelog.md, generate one in a similar format
let changelog = '# Changes in this Update\n\n';
// Get commit messages grouped by type
const { stdout: commitLog } = await execAsync(
`git log --pretty=format:"%h|%s|%b" ${currentCommit.trim()}..${remoteCommit.trim()}`,
);
const commits = commitLog.split('\n').filter(Boolean);
const categorizedCommits: Record<string, string[]> = {
'✨ Features': [],
'🐛 Bug Fixes': [],
'📚 Documentation': [],
'💎 Styles': [],
'♻️ Code Refactoring': [],
'⚡ Performance': [],
'🧪 Tests': [],
'🛠️ Build': [],
'⚙️ CI': [],
'🔍 Other Changes': [],
};
// Categorize commits
for (const commit of commits) {
const [hash, subject] = commit.split('|');
let category = '🔍 Other Changes';
if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
category = '✨ Features';
} else if (subject.startsWith('fix:')) {
category = '🐛 Bug Fixes';
} else if (subject.startsWith('docs:')) {
category = '📚 Documentation';
} else if (subject.startsWith('style:')) {
category = '💎 Styles';
} else if (subject.startsWith('refactor:')) {
category = '♻️ Code Refactoring';
} else if (subject.startsWith('perf:')) {
category = '⚡ Performance';
} else if (subject.startsWith('test:')) {
category = '🧪 Tests';
} else if (subject.startsWith('build:')) {
category = '🛠️ Build';
} else if (subject.startsWith('ci:')) {
category = '⚙️ CI';
}
const message = subject.includes(':') ? subject.split(':')[1].trim() : subject.trim();
categorizedCommits[category].push(`* ${message} (${hash.substring(0, 7)})`);
}
// Build changelog content
for (const [category, commits] of Object.entries(categorizedCommits)) {
if (commits.length > 0) {
changelog += `\n## ${category}\n\n${commits.join('\n')}\n`;
}
}
// Add stats
const { stdout: stats } = await execAsync(`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`);
if (stats) {
changelog += '\n## 📊 Stats\n\n';
changelog += `${stats.trim()}\n`;
}
return changelog;
} catch (error) {
console.error('Error fetching changelog:', error);
return 'Unable to fetch changelog';
}
}

72
app/styles/diff-view.css Normal file
View File

@@ -0,0 +1,72 @@
.diff-panel-content {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.diff-panel-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.diff-panel-content::-webkit-scrollbar-track {
background: transparent;
}
.diff-panel-content::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 4px;
border: 2px solid transparent;
}
.diff-panel-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(155, 155, 155, 0.7);
}
/* Hide scrollbar for the left panel when not hovered */
.diff-panel:not(:hover) .diff-panel-content::-webkit-scrollbar {
display: none;
}
.diff-panel:not(:hover) .diff-panel-content {
scrollbar-width: none;
}
/* Estilos para as linhas de diff */
.diff-block-added {
@apply bg-green-500/20 border-l-4 border-green-500;
}
.diff-block-removed {
@apply bg-red-500/20 border-l-4 border-red-500;
}
/* Melhorar contraste para mudanças */
.diff-panel-content .group:hover .diff-block-added {
@apply bg-green-500/30;
}
.diff-panel-content .group:hover .diff-block-removed {
@apply bg-red-500/30;
}
/* Estilos unificados para ambas as visualizações */
.diff-line {
@apply flex group min-w-fit transition-colors duration-150;
}
.diff-line-number {
@apply w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1;
}
.diff-line-content {
@apply px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary;
}
/* Cores específicas para adições/remoções */
.diff-added {
@apply bg-green-500/20 border-l-4 border-green-500;
}
.diff-removed {
@apply bg-red-500/20 border-l-4 border-red-500;
}

View File

@@ -1,3 +1,5 @@
import type { Change } from 'diff';
export type ActionType = 'file' | 'shell';
export interface BaseAction {
@@ -17,7 +19,11 @@ export interface StartAction extends BaseAction {
type: 'start';
}
export type BoltAction = FileAction | ShellAction | StartAction;
export interface BuildAction extends BaseAction {
type: 'build';
}
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
export type BoltActionData = BoltAction | BaseAction;
@@ -28,3 +34,16 @@ export interface ActionAlert {
content: string;
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
}
export interface FileHistory {
originalContent: string;
lastModified: number;
changes: Change[];
versions: {
timestamp: number;
content: string;
}[];
// Novo campo para rastrear a origem das mudanças
changeSource?: 'user' | 'auto-save' | 'external';
}

41
app/types/netlify.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface NetlifySite {
id: string;
name: string;
url: string;
admin_url: string;
build_settings: {
provider: string;
repo_url: string;
cmd: string;
};
published_deploy: {
published_at: string;
deploy_time: number;
};
}
export interface NetlifyUser {
id: string;
slug: string;
email: string;
full_name: string;
avatar_url: string;
}
export interface NetlifyStats {
sites: NetlifySite[];
totalSites: number;
}
export interface NetlifyConnection {
user: NetlifyUser | null;
token: string;
stats?: NetlifyStats;
}
export interface NetlifySiteInfo {
id: string;
name: string;
url: string;
chatId: string;
}

View File

@@ -103,3 +103,19 @@ export const detectProjectType = async (
return { type: '', setupCommand: '', followupMessage: '' };
};
export const filesToArtifacts = (files: { [path: string]: { content: string } }, id: string): string => {
return `
<boltArtifact id="${id}" title="User Updated Files">
${Object.keys(files)
.map(
(filePath) => `
<boltAction type="file" filePath="${filePath}">
${files[filePath].content}
</boltAction>
`,
)
.join('\n')}
</boltArtifact>
`;
};

View File

@@ -0,0 +1,24 @@
export const getLanguageFromExtension = (ext: string): string => {
const map: Record<string, string> = {
js: 'javascript',
jsx: 'jsx',
ts: 'typescript',
tsx: 'tsx',
json: 'json',
html: 'html',
css: 'css',
py: 'python',
java: 'java',
rb: 'ruby',
cpp: 'cpp',
c: 'c',
cs: 'csharp',
go: 'go',
rs: 'rust',
php: 'php',
swift: 'swift',
md: 'plaintext',
sh: 'bash',
};
return map[ext] || 'typescript';
};

View File

@@ -1,21 +1,72 @@
Release v0.0.6
# 🚀 Release v0.0.7
## What's Changed 🌟
### 🔄 Changes since v0.0.5
### 🔄 Changes since v0.0.6
### ✨ Features
- implement Claude 3, Claude3.5, Nova Pro, Nova Lite and Mistral model integration with AWS Bedrock ([#974](https://github.com/stackblitz-labs/bolt.diy/pull/974)) by @kunjabijukchhe
- enhance chat import with multi-format support ([#936](https://github.com/stackblitz-labs/bolt.diy/pull/936)) by @sidbetatester
- added Github provider ([#1109](https://github.com/stackblitz-labs/bolt.diy/pull/1109)) by @newnol
- added the "Open Preview in a New Tab" ([#1101](https://github.com/stackblitz-labs/bolt.diy/pull/1101)) by @Stijnus
- configure dynamic providers via .env ([#1108](https://github.com/stackblitz-labs/bolt.diy/pull/1108)) by @mrsimpson
- added deepseek reasoner model in deepseek provider ([#1151](https://github.com/stackblitz-labs/bolt.diy/pull/1151)) by @thecodacus
- enhance context handling by adding code context selection and implementing summary generation ([#1091](https://github.com/stackblitz-labs/bolt.diy/pull/1091)) by @thecodacus
* added support for reasoning content ([#1168](https://github.com/stackblitz-labs/bolt.diy/pull/1168)) by @thecodacus
* add deepseek-r1-distill-llama-70b to groq provider ([#1187](https://github.com/stackblitz-labs/bolt.diy/pull/1187)) by @saif78642
* add Gemini 2.0 Flash-thinking-exp-01-21 model with 65k token support ([#1202](https://github.com/stackblitz-labs/bolt.diy/pull/1202)) by @saif78642
* added more dynamic models, sorted and remove duplicate models ([#1206](https://github.com/stackblitz-labs/bolt.diy/pull/1206)) by @thecodacus
* support for <think></think> tags to allow reasoning tokens formatted in UI ([#1205](https://github.com/stackblitz-labs/bolt.diy/pull/1205)) by @thecodacus
* enhanced Code Context and Project Summary Features ([#1191](https://github.com/stackblitz-labs/bolt.diy/pull/1191)) by @thecodacus
* added dynamic model support for openAI provider ([#1241](https://github.com/stackblitz-labs/bolt.diy/pull/1241)) by @thecodacus
* bolt dyi new settings UI V3 ([#1245](https://github.com/stackblitz-labs/bolt.diy/pull/1245)) by @Stijnus
* implement llm model search ([#1322](https://github.com/stackblitz-labs/bolt.diy/pull/1322)) by @kamilfurtak
* diff-view-v2-no-conflict ([#1335](https://github.com/stackblitz-labs/bolt.diy/pull/1335)) by @Toddyclipsgg
* netlify one click deployment ([#1376](https://github.com/stackblitz-labs/bolt.diy/pull/1376)) by @xKevIsDev
* diff view v3 ([#1367](https://github.com/stackblitz-labs/bolt.diy/pull/1367)) by @Toddyclipsgg
* added anthropic dynamic models ([#1374](https://github.com/stackblitz-labs/bolt.diy/pull/1374)) by @thecodacus
* make user made changes persistent after reload ([#1387](https://github.com/stackblitz-labs/bolt.diy/pull/1387)) by @thecodacus
### 🐛 Bug Fixes
* docker prod env variable fix ([#1170](https://github.com/stackblitz-labs/bolt.diy/pull/1170)) by @thecodacus
* improve push to github option ([#1111](https://github.com/stackblitz-labs/bolt.diy/pull/1111)) by @thecodacus
* git import issue when importing bolt on bolt ([#1020](https://github.com/stackblitz-labs/bolt.diy/pull/1020)) by @thecodacus
* issue with alternate message when importing from folder and git ([#1216](https://github.com/stackblitz-labs/bolt.diy/pull/1216)) by @thecodacus
* tune the system prompt to avoid diff writing ([#1218](https://github.com/stackblitz-labs/bolt.diy/pull/1218)) by @thecodacus
* removed chrome canary note (6a8449e) by @leex279
* starter template icons fix and auto resize of custon icons are reverted ([#1298](https://github.com/stackblitz-labs/bolt.diy/pull/1298)) by @thecodacus
* auto scroll fix, scroll allow user to scroll up during ai response ([#1299](https://github.com/stackblitz-labs/bolt.diy/pull/1299)) by @thecodacus
* bug fix New UI / Feature tab - Default values hard-coded (294adfd) by @leex279
* debounce profile update notifications to prevent toast spam (70b723d) by @xKevIsDev
* bolt dyi UI bugfix ([#1342](https://github.com/stackblitz-labs/bolt.diy/pull/1342)) by @Stijnus
* preserve complete provider settings in cookies (220e2da) by @xKevIsDev
* for remove settings icon _index.tsx ([#1356](https://github.com/stackblitz-labs/bolt.diy/pull/1356)) by @Stijnus
* fix enhance prompt to stop implementing full project instead of enhancing ([#1383](https://github.com/stackblitz-labs/bolt.diy/pull/1383)) by @thecodacus
### ⚙️ CI
* updated Dockerfile to install latest version of corepack to ensure to have the right version to pnpm (c88938c) by @BaptisteCDC
### 🔍 Other Changes
* new anthropogenic model for amazon bedrock (0fd039b) by @leex279
* This reverts commit 871aefbe83c31660b32b53b63772ebba33ed7954, reversing ([#1335](https://github.com/stackblitz-labs/bolt.diy/pull/1335)) by @Toddyclipsgg
* Update docker.yaml (stable/main deployment) (f0ea22e) by @leex279
* Update Dockerfile - Test Bugfix Dockerpipeline (8e790d0) by @leex279
* Update Dockerfile (5297081) by @leex279
* Update docker.yaml (7dda793) by @leex279
* Update docker.yaml (67c4051) by @leex279
* Fix broken astro project git clone ([#1352](https://github.com/stackblitz-labs/bolt.diy/pull/1352)) by @Phr33d0m
## ✨ First-time Contributors
A huge thank you to our amazing new contributors! Your first contribution marks the start of an exciting journey! 🌟
* 🌟 [@BaptisteCDC](https://github.com/BaptisteCDC)
* 🌟 [@Phr33d0m](https://github.com/Phr33d0m)
* 🌟 [@kamilfurtak](https://github.com/kamilfurtak)
* 🌟 [@saif78642](https://github.com/saif78642)
* 🌟 [@xKevIsDev](https://github.com/xKevIsDev)
## 📈 Stats
**Full Changelog**: [`v0.0.5..v0.0.6`](https://github.com/stackblitz-labs/bolt.diy/compare/v0.0.5...v0.0.6)
**Full Changelog**: [`v0.0.6..v0.0.7`](https://github.com/stackblitz-labs/bolt.diy/compare/v0.0.6...v0.0.7)

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"sideEffects": false,
"type": "module",
"version": "0.0.6",
"version": "0.0.7",
"scripts": {
"deploy": "npm run build && wrangler pages deploy",
"build": "remix vite:build",
@@ -78,6 +78,7 @@
"@remix-run/cloudflare-pages": "^2.15.2",
"@remix-run/node": "^2.15.2",
"@remix-run/react": "^2.15.2",
"@tanstack/react-virtual": "^3.13.0",
"@types/react-beautiful-dnd": "^13.1.8",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
@@ -122,6 +123,7 @@
"remark-gfm": "^4.0.0",
"remix-island": "^0.2.0",
"remix-utils": "^7.7.0",
"rollup-plugin-node-polyfills": "^0.2.1",
"shiki": "^1.24.0",
"tailwind-merge": "^2.2.1",
"unist-util-visit": "^5.0.0",
@@ -133,6 +135,8 @@
"@iconify-json/ph": "^1.2.1",
"@iconify/types": "^2.0.0",
"@remix-run/dev": "^2.15.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4",
"@types/file-saver": "^2.0.7",
@@ -140,9 +144,11 @@
"@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"fast-glob": "^3.3.2",
"husky": "9.1.7",
"is-ci": "^3.0.1",
"jsdom": "^26.0.0",
"node-fetch": "^3.3.2",
"pnpm": "^9.14.4",
"prettier": "^3.4.1",

1227
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -98,9 +98,7 @@ const COLOR_PRIMITIVES = {
};
export default defineConfig({
safelist: [
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
],
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
shortcuts: {
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',

View File

@@ -89,14 +89,55 @@ export default defineConfig((config) => {
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
// Define global values
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
build: {
target: 'esnext',
rollupOptions: {
output: {
format: 'esm',
},
},
commonjsOptions: {
transformMixedEsModules: true,
},
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
resolve: {
alias: {
buffer: 'vite-plugin-node-polyfills/polyfills/buffer',
},
},
plugins: [
nodePolyfills({
include: ['path', 'buffer', 'process'],
include: ['buffer', 'process', 'util', 'stream'],
globals: {
Buffer: true,
process: true,
global: true,
},
protocolImports: true,
// Exclude Node.js modules that shouldn't be polyfilled in Cloudflare
exclude: ['child_process', 'fs', 'path'],
}),
{
name: 'buffer-polyfill',
transform(code, id) {
if (id.includes('env.mjs')) {
return {
code: `import { Buffer } from 'buffer';\n${code}`,
map: null,
};
}
},
},
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {

View File

@@ -3,4 +3,4 @@ name = "bolt"
compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-07-01"
pages_build_output_dir = "./build/client"
send_metrics = false
send_metrics = false