1 Commits

Author SHA1 Message Date
Cole Medin
3d0f8e8f03 Updating copyright in LICENSE 2024-12-17 09:26:06 -06:00
463 changed files with 9171 additions and 74949 deletions

View File

@@ -1,28 +0,0 @@
{
"ignoreMatches": [
"@types/*",
"eslint-*",
"prettier*",
"husky",
"rimraf",
"vitest",
"vite",
"typescript",
"wrangler",
"electron*"
],
"ignoreDirs": [
"dist",
"build",
"node_modules",
".git"
],
"skipMissing": false,
"ignorePatterns": [
"*.d.ts",
"*.test.ts",
"*.test.tsx",
"*.spec.ts",
"*.spec.tsx"
]
}

View File

@@ -1,221 +1,87 @@
# ======================================
# Environment Variables for Bolt.diy
# ======================================
# Copy this file to .env.local and fill in your API keys
# See README.md for setup instructions
# Rename this file to .env once you have filled in the below environment variables!
# ======================================
# AI PROVIDER API KEYS
# ======================================
# Get your GROQ API Key here -
# https://console.groq.com/keys
# You only need this environment variable set if you want to use Groq models
GROQ_API_KEY=
# Anthropic Claude
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Get your HuggingFace API Key here -
# https://huggingface.co/settings/tokens
# You only need this environment variable set if you want to use HuggingFace models
HuggingFace_API_KEY=
# Cerebras (High-performance inference)
# Get your API key from: https://cloud.cerebras.ai/settings
CEREBRAS_API_KEY=your_cerebras_api_key_here
# Fireworks AI (Fast inference with FireAttention engine)
# Get your API key from: https://fireworks.ai/api-keys
FIREWORKS_API_KEY=your_fireworks_api_key_here
# Get your Open AI API Key by following these instructions -
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
# You only need this environment variable set if you want to use GPT models
OPENAI_API_KEY=
# OpenAI GPT models
# Get your API key from: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here
# Get your Anthropic API Key in your account settings -
# https://console.anthropic.com/settings/keys
# You only need this environment variable set if you want to use Claude models
ANTHROPIC_API_KEY=
# GitHub Models (OpenAI models hosted by GitHub)
# Get your Personal Access Token from: https://github.com/settings/tokens
# - Select "Fine-grained tokens"
# - Set repository access to "All repositories"
# - Enable "GitHub Models" permission
GITHUB_API_KEY=github_pat_your_personal_access_token_here
# Get your OpenRouter API Key in your account settings -
# https://openrouter.ai/settings/keys
# You only need this environment variable set if you want to use OpenRouter models
OPEN_ROUTER_API_KEY=
# Perplexity AI (Search-augmented models)
# Get your API key from: https://www.perplexity.ai/settings/api
PERPLEXITY_API_KEY=your_perplexity_api_key_here
# Get your Google Generative AI API Key by following these instructions -
# https://console.cloud.google.com/apis/credentials
# You only need this environment variable set if you want to use Google Generative AI models
GOOGLE_GENERATIVE_AI_API_KEY=
# DeepSeek
# Get your API key from: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=your_deepseek_api_key_here
# You only need this environment variable set if you want to use oLLAMA models
# EXAMPLE http://localhost:11434
OLLAMA_API_BASE_URL=
# Google Gemini
# Get your API key from: https://makersuite.google.com/app/apikey
GOOGLE_GENERATIVE_AI_API_KEY=your_google_gemini_api_key_here
# You only need this environment variable set if you want to use OpenAI Like models
OPENAI_LIKE_API_BASE_URL=
# Cohere
# Get your API key from: https://dashboard.cohere.ai/api-keys
COHERE_API_KEY=your_cohere_api_key_here
# You only need this environment variable set if you want to use Together AI models
TOGETHER_API_BASE_URL=
# Groq (Fast inference)
# Get your API key from: https://console.groq.com/keys
GROQ_API_KEY=your_groq_api_key_here
# You only need this environment variable set if you want to use DeepSeek models through their API
DEEPSEEK_API_KEY=
# Mistral
# Get your API key from: https://console.mistral.ai/api-keys/
MISTRAL_API_KEY=your_mistral_api_key_here
# Get your OpenAI Like API Key
OPENAI_LIKE_API_KEY=
# Together AI
# Get your API key from: https://api.together.xyz/settings/api-keys
TOGETHER_API_KEY=your_together_api_key_here
# Get your Together API Key
TOGETHER_API_KEY=
# X.AI (Elon Musk's company)
# Get your API key from: https://console.x.ai/
XAI_API_KEY=your_xai_api_key_here
# Get your Mistral API Key by following these instructions -
# https://console.mistral.ai/api-keys/
# You only need this environment variable set if you want to use Mistral models
MISTRAL_API_KEY=
# Moonshot AI (Kimi models)
# Get your API key from: https://platform.moonshot.ai/console/api-keys
MOONSHOT_API_KEY=your_moonshot_api_key_here
# Get the Cohere Api key by following these instructions -
# https://dashboard.cohere.com/api-keys
# You only need this environment variable set if you want to use Cohere models
COHERE_API_KEY=
# Z.AI (GLM models with JWT authentication)
# Get your API key from: https://open.bigmodel.cn/usercenter/apikeys
ZAI_API_KEY=your_zai_api_key_here
# Get LMStudio Base URL from LM Studio Developer Console
# Make sure to enable CORS
# Example: http://localhost:1234
LMSTUDIO_API_BASE_URL=
# Hugging Face
# Get your API key from: https://huggingface.co/settings/tokens
HuggingFace_API_KEY=your_huggingface_api_key_here
# Get your xAI API key
# https://x.ai/api
# You only need this environment variable set if you want to use xAI models
XAI_API_KEY=
# Hyperbolic
# Get your API key from: https://app.hyperbolic.xyz/settings
HYPERBOLIC_API_KEY=your_hyperbolic_api_key_here
# Get your Perplexity API Key here -
# https://www.perplexity.ai/settings/api
# You only need this environment variable set if you want to use Perplexity models
PERPLEXITY_API_KEY=
# OpenRouter (Meta routing for multiple providers)
# Get your API key from: https://openrouter.ai/keys
OPEN_ROUTER_API_KEY=your_openrouter_api_key_here
# ======================================
# CUSTOM PROVIDER BASE URLS (Optional)
# ======================================
# Ollama (Local models)
# DON'T USE http://localhost:11434 due to IPv6 issues
# USE: http://127.0.0.1:11434
OLLAMA_API_BASE_URL=http://127.0.0.1:11434
# OpenAI-like API (Compatible providers)
OPENAI_LIKE_API_BASE_URL=your_openai_like_base_url_here
OPENAI_LIKE_API_KEY=your_openai_like_api_key_here
# Together AI Base URL
TOGETHER_API_BASE_URL=your_together_base_url_here
# Hyperbolic Base URL
HYPERBOLIC_API_BASE_URL=https://api.hyperbolic.xyz/v1/chat/completions
# LMStudio (Local models)
# Make sure to enable CORS in LMStudio
# DON'T USE http://localhost:1234 due to IPv6 issues
# USE: http://127.0.0.1:1234
LMSTUDIO_API_BASE_URL=http://127.0.0.1:1234
# ======================================
# CLOUD SERVICES CONFIGURATION
# ======================================
# AWS Bedrock Configuration (JSON format)
# Get your credentials from: https://console.aws.amazon.com/iam/home
# Example: {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey"}
AWS_BEDROCK_CONFIG=your_aws_bedrock_config_json_here
# ======================================
# GITHUB INTEGRATION
# ======================================
# GitHub Personal Access Token
# Get from: https://github.com/settings/tokens
# Used for importing/cloning repositories and accessing private repos
VITE_GITHUB_ACCESS_TOKEN=your_github_personal_access_token_here
# GitHub Token Type ('classic' or 'fine-grained')
VITE_GITHUB_TOKEN_TYPE=classic
# ======================================
# GITLAB INTEGRATION
# ======================================
# GitLab Personal Access Token
# Get your GitLab Personal Access Token here:
# https://gitlab.com/-/profile/personal_access_tokens
#
# This token is used for:
# 1. Importing/cloning GitLab repositories
# 2. Accessing private projects
# 3. Creating/updating branches
# 4. Creating/updating commits and pushing code
# 5. Creating new GitLab projects via the API
#
# Make sure your token has the following scopes:
# - api (for full API access including project creation and commits)
# - read_repository (to clone/import repositories)
# - write_repository (to push commits and update branches)
VITE_GITLAB_ACCESS_TOKEN=your_gitlab_personal_access_token_here
# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain)
VITE_GITLAB_URL=https://gitlab.com
# GitLab token type should be 'personal-access-token'
VITE_GITLAB_TOKEN_TYPE=personal-access-token
# ======================================
# VERCEL INTEGRATION
# ======================================
# Vercel Access Token
# Get your access token from: https://vercel.com/account/tokens
# This token is used for:
# 1. Deploying projects to Vercel
# 2. Managing Vercel projects and deployments
# 3. Accessing project analytics and logs
VITE_VERCEL_ACCESS_TOKEN=your_vercel_access_token_here
# ======================================
# NETLIFY INTEGRATION
# ======================================
# Netlify Access Token
# Get your access token from: https://app.netlify.com/user/applications
# This token is used for:
# 1. Deploying sites to Netlify
# 2. Managing Netlify sites and deployments
# 3. Accessing build logs and analytics
VITE_NETLIFY_ACCESS_TOKEN=your_netlify_access_token_here
# ======================================
# SUPABASE INTEGRATION
# ======================================
# Supabase Project Configuration
# Get your project details from: https://supabase.com/dashboard
# Select your project → Settings → API
VITE_SUPABASE_URL=your_supabase_project_url_here
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key_here
# Supabase Access Token (for management operations)
# Generate from: https://supabase.com/dashboard/account/tokens
VITE_SUPABASE_ACCESS_TOKEN=your_supabase_access_token_here
# ======================================
# DEVELOPMENT SETTINGS
# ======================================
# Development Mode
NODE_ENV=development
# Application Port (optional, defaults to 5173 for development)
PORT=5173
# Logging Level (debug, info, warn, error)
# Include this environment variable if you want more logging for debugging locally
VITE_LOG_LEVEL=debug
# Default Context Window Size (for local models)
DEFAULT_NUM_CTX=32768
# ======================================
# SETUP INSTRUCTIONS
# ======================================
# 1. Copy this file to .env.local: cp .env.example .env.local
# 2. Fill in the API keys for the services you want to use
# 3. All service integration keys use VITE_ prefix for auto-connection
# 4. Restart your development server: pnpm run dev
# 5. Services will auto-connect on startup if tokens are provided
# 6. Go to Settings > Service tabs to manage connections manually if needed
# Example Context Values for qwen2.5-coder:32b
#
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM
# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM
# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM
DEFAULT_NUM_CTX=

View File

@@ -1,142 +0,0 @@
# Rename this file to .env once you have filled in the below environment variables!
# Get your GROQ API Key here -
# https://console.groq.com/keys
# You only need this environment variable set if you want to use Groq models
GROQ_API_KEY=
# Get your HuggingFace API Key here -
# https://huggingface.co/settings/tokens
# You only need this environment variable set if you want to use HuggingFace models
HuggingFace_API_KEY=
# Get your Open AI API Key by following these instructions -
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
# You only need this environment variable set if you want to use GPT models
OPENAI_API_KEY=
# Get your Anthropic API Key in your account settings -
# https://console.anthropic.com/settings/keys
# You only need this environment variable set if you want to use Claude models
ANTHROPIC_API_KEY=
# Get your OpenRouter API Key in your account settings -
# https://openrouter.ai/settings/keys
# You only need this environment variable set if you want to use OpenRouter models
OPEN_ROUTER_API_KEY=
# Get your Google Generative AI API Key by following these instructions -
# https://console.cloud.google.com/apis/credentials
# You only need this environment variable set if you want to use Google Generative AI models
GOOGLE_GENERATIVE_AI_API_KEY=
# You only need this environment variable set if you want to use oLLAMA models
# DONT USE http://localhost:11434 due to IPV6 issues
# USE EXAMPLE http://127.0.0.1:11434
OLLAMA_API_BASE_URL=
# You only need this environment variable set if you want to use OpenAI Like models
OPENAI_LIKE_API_BASE_URL=
# You only need this environment variable set if you want to use Together AI models
TOGETHER_API_BASE_URL=
# You only need this environment variable set if you want to use DeepSeek models through their API
DEEPSEEK_API_KEY=
# Get your OpenAI Like API Key
OPENAI_LIKE_API_KEY=
# Get your Together API Key
TOGETHER_API_KEY=
# You only need this environment variable set if you want to use Hyperbolic models
HYPERBOLIC_API_KEY=
HYPERBOLIC_API_BASE_URL=
# Get your Mistral API Key by following these instructions -
# https://console.mistral.ai/api-keys/
# You only need this environment variable set if you want to use Mistral models
MISTRAL_API_KEY=
# Get the Cohere Api key by following these instructions -
# https://dashboard.cohere.com/api-keys
# You only need this environment variable set if you want to use Cohere models
COHERE_API_KEY=
# Get LMStudio Base URL from LM Studio Developer Console
# Make sure to enable CORS
# DONT USE http://localhost:1234 due to IPV6 issues
# Example: http://127.0.0.1:1234
LMSTUDIO_API_BASE_URL=
# Get your xAI API key
# https://x.ai/api
# You only need this environment variable set if you want to use xAI models
XAI_API_KEY=
# Get your Perplexity API Key here -
# https://www.perplexity.ai/settings/api
# You only need this environment variable set if you want to use Perplexity models
PERPLEXITY_API_KEY=
# Get your AWS configuration
# https://console.aws.amazon.com/iam/home
AWS_BEDROCK_CONFIG=
# Include this environment variable if you want more logging for debugging locally
VITE_LOG_LEVEL=
# Get your GitHub Personal Access Token here -
# https://github.com/settings/tokens
# This token is used for:
# 1. Importing/cloning GitHub repositories without rate limiting
# 2. Accessing private repositories
# 3. Automatic GitHub authentication (no need to manually connect in the UI)
#
# For classic tokens, ensure it has these scopes: repo, read:org, read:user
# For fine-grained tokens, ensure it has Repository and Organization access
VITE_GITHUB_ACCESS_TOKEN=
# Specify the type of GitHub token you're using
# Can be 'classic' or 'fine-grained'
# Classic tokens are recommended for broader access
VITE_GITHUB_TOKEN_TYPE=
# ======================================
# SERVICE INTEGRATIONS
# ======================================
# GitLab Personal Access Token
# Get your GitLab Personal Access Token here:
# https://gitlab.com/-/profile/personal_access_tokens
# Required scopes: api, read_repository, write_repository
VITE_GITLAB_ACCESS_TOKEN=
# GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain)
VITE_GITLAB_URL=https://gitlab.com
# GitLab token type
VITE_GITLAB_TOKEN_TYPE=personal-access-token
# Vercel Access Token
# Get your access token from: https://vercel.com/account/tokens
VITE_VERCEL_ACCESS_TOKEN=
# Netlify Access Token
# Get your access token from: https://app.netlify.com/user/applications
VITE_NETLIFY_ACCESS_TOKEN=
# Supabase Configuration
# Get your project details from: https://supabase.com/dashboard
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
VITE_SUPABASE_ACCESS_TOKEN=
# Example Context Values for qwen2.5-coder:32b
#
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM
# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM
# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM
DEFAULT_NUM_CTX=

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
# Code Owners for bolt.diy
# These users/teams will automatically be requested for review when files are modified
# Global ownership - repository maintainers
* @stackblitz-labs/bolt-maintainers
# GitHub workflows and CI/CD configuration - require maintainer review
/.github/ @stackblitz-labs/bolt-maintainers
/package.json @stackblitz-labs/bolt-maintainers
/pnpm-lock.yaml @stackblitz-labs/bolt-maintainers
# Security-sensitive configurations - require maintainer review
/.env* @stackblitz-labs/bolt-maintainers
/wrangler.toml @stackblitz-labs/bolt-maintainers
/Dockerfile @stackblitz-labs/bolt-maintainers
/docker-compose.yaml @stackblitz-labs/bolt-maintainers
# Core application architecture - require maintainer review
/app/lib/.server/ @stackblitz-labs/bolt-maintainers
/app/routes/api.* @stackblitz-labs/bolt-maintainers
# Build and deployment configuration - require maintainer review
/vite*.config.ts @stackblitz-labs/bolt-maintainers
/tsconfig.json @stackblitz-labs/bolt-maintainers
/uno.config.ts @stackblitz-labs/bolt-maintainers
/eslint.config.mjs @stackblitz-labs/bolt-maintainers
# Documentation (optional review)
/*.md
/docs/

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ inputs:
pnpm-version:
required: false
type: string
default: '9.14.4'
default: '9.4.0'
node-version:
required: false
type: string
default: '20.18.0'
default: '20.15.1'
runs:
using: composite

View File

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

View File

@@ -3,20 +3,13 @@ name: CI/CD
on:
push:
branches:
- main
- master
pull_request:
# Cancel in-progress runs on the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -24,67 +17,11 @@ jobs:
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Cache TypeScript compilation
uses: actions/cache@v4
with:
path: |
.tsbuildinfo
node_modules/.cache
key: ${{ runner.os }}-typescript-${{ hashFiles('**/tsconfig.json', 'app/**/*.ts', 'app/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-typescript-
- name: Run type check
run: pnpm run typecheck
- name: Cache ESLint
uses: actions/cache@v4
with:
path: node_modules/.cache/eslint
key: ${{ runner.os }}-eslint-${{ hashFiles('.eslintrc*', 'app/**/*.ts', 'app/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-eslint-
- name: Run ESLint
run: pnpm run lint
# - name: Run ESLint
# run: pnpm run lint
- name: Run tests
run: pnpm run test
- name: Upload test coverage
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
docker-validation:
name: Docker Build Validation
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Validate Docker production build
run: |
echo "🐳 Testing Docker production target..."
docker build --target bolt-ai-production . --no-cache --progress=plain
echo "✅ Production target builds successfully"
- name: Validate Docker development build
run: |
echo "🐳 Testing Docker development target..."
docker build --target development . --no-cache --progress=plain
echo "✅ Development target builds successfully"
- name: Validate docker-compose configuration
run: |
echo "🐳 Validating docker-compose configuration..."
docker compose config --quiet
echo "✅ docker-compose configuration is valid"

39
.github/workflows/commit.yaml vendored Normal file
View File

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

View File

@@ -1,67 +0,0 @@
name: Docker Publish
on:
push:
branches: [main, stable]
tags: ['v*', '*.*.*']
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
packages: write
contents: read
id-token: write
env:
REGISTRY: ghcr.io
jobs:
docker-build-publish:
runs-on: ubuntu-latest
# timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set lowercase image name
id: image
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.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: .
platforms: linux/amd64,linux/arm64
target: bolt-ai-production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Check manifest
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}

View File

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

View File

@@ -1,98 +0,0 @@
name: Electron Build and Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag for the release (e.g., v1.0.0). Leave empty if not applicable.'
required: false
push:
branches:
- electron
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest] # Use unsigned macOS builds for now
node-version: [20.18.0]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.14.4
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
# Install Linux dependencies
- name: Install Linux dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y rpm
# Build
- name: Build Electron app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: "--max_old_space_size=4096"
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
pnpm run electron:build:win
elif [ "$RUNNER_OS" == "macOS" ]; then
pnpm run electron:build:mac
else
pnpm run electron:build:linux
fi
shell: bash
# Create Release
- name: Create Release
uses: softprops/action-gh-release@v2
with:
# Use the workflow_dispatch input tag if available, else use the Git ref name.
tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
# Only branch pushes remain drafts. For workflow_dispatch and tag pushes the release is published.
draft: ${{ github.event_name != 'workflow_dispatch' && github.ref_type == 'branch' }}
# For tag pushes, name the release as "Release <tagname>", otherwise "Electron Release".
name: ${{ (github.event_name == 'push' && github.ref_type == 'tag') && format('Release {0}', github.ref_name) || 'Electron Release' }}
files: |
dist/*.exe
dist/*.dmg
dist/*.deb
dist/*.AppImage
dist/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,80 +6,13 @@ on:
branches:
- main
permissions:
contents: read
pull-requests: write
checks: write
jobs:
quality-gates:
name: Quality Gates
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Wait for CI checks
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: 'Test'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
- name: Check required status checks
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha
});
const requiredChecks = ['Test', 'CodeQL Analysis'];
const optionalChecks = ['Quality Analysis', 'Deploy Preview'];
const failedChecks = [];
const passedChecks = [];
// Check required workflows
for (const checkName of requiredChecks) {
const check = checks.check_runs.find(c => c.name === checkName);
if (check && check.conclusion === 'success') {
passedChecks.push(checkName);
} else {
failedChecks.push(checkName);
}
}
// Report optional checks
for (const checkName of optionalChecks) {
const check = checks.check_runs.find(c => c.name === checkName);
if (check && check.conclusion === 'success') {
passedChecks.push(`${checkName} (optional)`);
}
}
console.log(`✅ Passed checks: ${passedChecks.join(', ')}`);
if (failedChecks.length > 0) {
console.log(`❌ Failed required checks: ${failedChecks.join(', ')}`);
core.setFailed(`Required checks failed: ${failedChecks.join(', ')}`);
} else {
console.log(`✅ All required checks passed!`);
}
validate-release:
name: Release Validation
runs-on: ubuntu-latest
needs: quality-gates
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Validate PR Labels
run: |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then
@@ -95,31 +28,4 @@ jobs:
fi
else
echo "This PR doesn't have the stable-release label. No release will be created."
fi
- name: Check breaking changes
if: contains(github.event.pull_request.labels.*.name, 'major')
run: |
echo "⚠️ This PR contains breaking changes and will trigger a major release."
- name: Validate changelog entry
if: contains(github.event.pull_request.labels.*.name, 'stable-release')
run: |
if ! grep -q "${{ github.event.pull_request.number }}" CHANGES.md; then
echo "❌ No changelog entry found for PR #${{ github.event.pull_request.number }}"
echo "Please add an entry to CHANGES.md"
exit 1
else
echo "✓ Changelog entry found"
fi
security-review:
name: Security Review Required
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'security')
steps:
- name: Check security label
run: |
echo "🔒 This PR has security implications and requires additional review"
echo "Ensure a security team member has approved this PR before merging"
fi

View File

@@ -1,196 +0,0 @@
name: Preview Deployment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
# Cancel in-progress runs on the same PR
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
deploy-preview:
name: Deploy Preview
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- name: Check if preview deployment is configured
id: check-secrets
run: |
if [[ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" && -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]]; then
echo "configured=true" >> $GITHUB_OUTPUT
else
echo "configured=false" >> $GITHUB_OUTPUT
fi
- name: Checkout
if: steps.check-secrets.outputs.configured == 'true'
uses: actions/checkout@v4
- name: Setup and Build
if: steps.check-secrets.outputs.configured == 'true'
uses: ./.github/actions/setup-and-build
- name: Build for production
if: steps.check-secrets.outputs.configured == 'true'
run: pnpm run build
env:
NODE_ENV: production
- name: Deploy to Cloudflare Pages
if: steps.check-secrets.outputs.configured == 'true'
id: deploy
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: bolt-diy-preview
directory: build/client
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Preview deployment not configured
if: steps.check-secrets.outputs.configured == 'false'
run: |
echo "✅ Preview deployment is not configured for this repository"
echo "To enable preview deployments, add the following secrets:"
echo "- CLOUDFLARE_API_TOKEN"
echo "- CLOUDFLARE_ACCOUNT_ID"
echo "This is optional and the workflow will pass without it."
echo "url=https://preview-not-configured.example.com" >> $GITHUB_OUTPUT
- name: Add preview URL comment to PR
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const previewComment = comments.find(comment =>
comment.body.includes('🚀 Preview deployment')
);
const isConfigured = '${{ steps.check-secrets.outputs.configured }}' === 'true';
const deployUrl = '${{ steps.deploy.outputs.url }}' || 'https://preview-not-configured.example.com';
let commentBody;
if (isConfigured) {
commentBody = `🚀 Preview deployment is ready!
| Name | Link |
|------|------|
| Latest commit | ${{ github.sha }} |
| Preview URL | ${deployUrl} |
Built with ❤️ by [bolt.diy](https://bolt.diy)
`;
} else {
commentBody = ` Preview deployment not configured
| Name | Info |
|------|------|
| Latest commit | ${{ github.sha }} |
| Status | Preview deployment requires Cloudflare secrets |
To enable preview deployments, repository maintainers can add:
- \`CLOUDFLARE_API_TOKEN\` secret
- \`CLOUDFLARE_ACCOUNT_ID\` secret
Built with ❤️ by [bolt.diy](https://bolt.diy)
`;
}
if (previewComment) {
github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: previewComment.id,
body: commentBody
});
} else {
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});
}
- name: Run smoke tests on preview
run: |
if [[ "${{ steps.check-secrets.outputs.configured }}" == "true" ]]; then
echo "Running smoke tests on preview deployment..."
echo "Preview URL: ${{ steps.deploy.outputs.url }}"
# Basic HTTP check instead of Playwright tests
curl -f ${{ steps.deploy.outputs.url }} || echo "Preview environment check completed"
else
echo "✅ Smoke tests skipped - preview deployment not configured"
echo "This is normal and expected when Cloudflare secrets are not available"
fi
- name: Preview workflow summary
run: |
echo "✅ Preview deployment workflow completed successfully"
if [[ "${{ steps.check-secrets.outputs.configured }}" == "true" ]]; then
echo "🚀 Preview deployed to: ${{ steps.deploy.outputs.url }}"
else
echo " Preview deployment not configured (this is normal)"
fi
cleanup-preview:
name: Cleanup Preview
runs-on: ubuntu-latest
if: github.event.action == 'closed'
steps:
- name: Delete preview environment
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-pr-${{ github.event.pull_request.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}
- name: Remove preview comment
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
for (const comment of comments) {
if (comment.body.includes('🚀 Preview deployment')) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
}
}

View File

@@ -1,181 +0,0 @@
name: Code Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel in-progress runs on the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality-checks:
name: Quality Analysis
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Check for duplicate dependencies
run: |
echo "Checking for duplicate dependencies..."
pnpm dedupe --check || echo "✅ Duplicate dependency check completed"
- name: Check bundle size
run: |
pnpm run build
echo "Bundle analysis completed (bundlesize tool requires configuration)"
continue-on-error: true
- name: Dead code elimination check
run: |
echo "Checking for unused imports and dead code..."
npx unimported || echo "Unimported tool completed with warnings"
continue-on-error: true
- name: Check for unused dependencies
run: |
echo "Checking for unused dependencies..."
npx depcheck --config .depcheckrc.json || echo "Dependency check completed with findings"
continue-on-error: true
- name: Check package.json formatting
run: |
echo "Checking package.json formatting..."
npx sort-package-json package.json --check || echo "Package.json formatting check completed"
continue-on-error: true
- name: Generate complexity report
run: |
echo "Analyzing code complexity..."
npx es6-plato -r -d complexity-report app/ || echo "Complexity analysis completed"
continue-on-error: true
- name: Upload complexity report
uses: actions/upload-artifact@v4
if: always()
with:
name: complexity-report
path: complexity-report/
retention-days: 7
accessibility-tests:
name: Accessibility Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Start development server
run: |
pnpm run build
pnpm run start &
sleep 15
env:
CI: true
- name: Run accessibility tests with axe
run: |
echo "Running accessibility tests..."
npx @axe-core/cli http://localhost:5173 --exit || echo "Accessibility tests completed with findings"
continue-on-error: true
performance-audit:
name: Performance Audit
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Start server for Lighthouse
run: |
pnpm run build
pnpm run start &
sleep 20
- name: Run Lighthouse audit
run: |
echo "Running Lighthouse performance audit..."
npx lighthouse http://localhost:5173 --output-path=./lighthouse-report.html --output=html --chrome-flags="--headless --no-sandbox" || echo "Lighthouse audit completed"
continue-on-error: true
- name: Upload Lighthouse report
uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-report
path: lighthouse-report.html
retention-days: 7
pr-size-check:
name: PR Size Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate PR size
id: pr-size
run: |
# Get the base branch (target branch)
BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
# Count additions and deletions
ADDITIONS=$(git diff --numstat origin/$BASE_BRANCH...HEAD | awk '{sum += $1} END {print sum}')
DELETIONS=$(git diff --numstat origin/$BASE_BRANCH...HEAD | awk '{sum += $2} END {print sum}')
TOTAL_CHANGES=$((ADDITIONS + DELETIONS))
echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT
echo "deletions=$DELETIONS" >> $GITHUB_OUTPUT
echo "total=$TOTAL_CHANGES" >> $GITHUB_OUTPUT
# Determine size category
if [ $TOTAL_CHANGES -lt 50 ]; then
echo "size=XS" >> $GITHUB_OUTPUT
elif [ $TOTAL_CHANGES -lt 200 ]; then
echo "size=S" >> $GITHUB_OUTPUT
elif [ $TOTAL_CHANGES -lt 500 ]; then
echo "size=M" >> $GITHUB_OUTPUT
elif [ $TOTAL_CHANGES -lt 1000 ]; then
echo "size=L" >> $GITHUB_OUTPUT
elif [ $TOTAL_CHANGES -lt 2000 ]; then
echo "size=XL" >> $GITHUB_OUTPUT
else
echo "size=XXL" >> $GITHUB_OUTPUT
fi
- name: PR size summary
run: |
echo "✅ PR Size Analysis Complete"
echo "📊 Changes: +${{ steps.pr-size.outputs.additions }} -${{ steps.pr-size.outputs.deletions }}"
echo "📏 Size Category: ${{ steps.pr-size.outputs.size }}"
echo "💡 This information helps reviewers understand the scope of changes"
if [ "${{ steps.pr-size.outputs.size }}" = "XXL" ]; then
echo " This is a large PR - consider breaking it into smaller chunks for future PRs"
echo "However, large PRs are acceptable for major feature additions like this one"
fi

View File

@@ -1,121 +0,0 @@
name: Security Analysis
on:
push:
branches: [main, stable]
pull_request:
branches: [main]
schedule:
# Run weekly security scan on Sundays at 2 AM
- cron: '0 2 * * 0'
permissions:
actions: read
contents: read
security-events: read
jobs:
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
language: ['javascript', 'typescript']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
upload: false
output: "codeql-results"
- name: Upload CodeQL results as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: codeql-results-${{ matrix.language }}
path: codeql-results
dependency-scan:
name: Dependency Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.18.0'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: '9.14.4'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run npm audit
run: pnpm audit --audit-level moderate
continue-on-error: true
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
path: ./
format: spdx-json
artifact-name: sbom.spdx.json
- name: Upload SBOM as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: sbom-results
path: |
sbom.spdx.json
**/sbom.spdx.json
secrets-scan:
name: Secrets Detection
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Trivy secrets scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-secrets-results.sarif'
scanners: 'secret'
- name: Upload Trivy secrets results as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-secrets-results
path: trivy-secrets-results.sarif

View File

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

View File

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

View File

@@ -1,247 +0,0 @@
name: Test Workflows
# This workflow is for testing our new workflow changes safely
on:
push:
branches: [workflow-testing, test-*]
pull_request:
branches: [workflow-testing]
workflow_dispatch:
inputs:
test_type:
description: 'Type of test to run'
required: true
default: 'all'
type: choice
options:
- all
- ci-only
- security-only
- quality-only
jobs:
workflow-test-info:
name: Workflow Test Information
runs-on: ubuntu-latest
steps:
- name: Display test information
run: |
echo "🧪 Testing new workflow configurations"
echo "Branch: ${{ github.ref_name }}"
echo "Event: ${{ github.event_name }}"
echo "Test type: ${{ github.event.inputs.test_type || 'all' }}"
echo ""
echo "This is a safe test environment - no changes will affect production workflows"
test-basic-setup:
name: Test Basic Setup
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Test setup-and-build action
uses: ./.github/actions/setup-and-build
- name: Verify Node.js version
run: |
echo "Node.js version: $(node --version)"
if [[ "$(node --version)" == *"20.18.0"* ]]; then
echo "✅ Correct Node.js version"
else
echo "❌ Wrong Node.js version"
exit 1
fi
- name: Verify pnpm version
run: |
echo "pnpm version: $(pnpm --version)"
if [[ "$(pnpm --version)" == *"9.14.4"* ]]; then
echo "✅ Correct pnpm version"
else
echo "❌ Wrong pnpm version"
exit 1
fi
- name: Test build process
run: |
echo "✅ Build completed successfully"
test-linting:
name: Test Linting
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Test ESLint
run: |
echo "Testing ESLint configuration..."
pnpm run lint --max-warnings 0 || echo "ESLint found issues (expected for testing)"
- name: Test TypeScript
run: |
echo "Testing TypeScript compilation..."
pnpm run typecheck
test-caching:
name: Test Caching Strategy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Test TypeScript cache
uses: actions/cache@v4
with:
path: |
.tsbuildinfo
node_modules/.cache
key: test-${{ runner.os }}-typescript-${{ hashFiles('**/tsconfig.json', 'app/**/*.ts', 'app/**/*.tsx') }}
restore-keys: |
test-${{ runner.os }}-typescript-
- name: Test ESLint cache
uses: actions/cache@v4
with:
path: node_modules/.cache/eslint
key: test-${{ runner.os }}-eslint-${{ hashFiles('.eslintrc*', 'app/**/*.ts', 'app/**/*.tsx') }}
restore-keys: |
test-${{ runner.os }}-eslint-
- name: Verify caching works
run: |
echo "✅ Caching configuration tested"
test-security-tools:
name: Test Security Tools
runs-on: ubuntu-latest
if: github.event.inputs.test_type == 'all' || github.event.inputs.test_type == 'security-only'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.18.0'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: '9.14.4'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test dependency audit (non-blocking)
run: |
echo "Testing pnpm audit..."
pnpm audit --audit-level moderate || echo "Audit found issues (this is for testing)"
- name: Test Trivy installation
run: |
echo "Testing Trivy secrets scanner..."
docker run --rm -v ${{ github.workspace }}:/workspace aquasecurity/trivy:latest fs /workspace --exit-code 0 --no-progress --format table --scanners secret || echo "Trivy test completed"
test-quality-checks:
name: Test Quality Checks
runs-on: ubuntu-latest
if: github.event.inputs.test_type == 'all' || github.event.inputs.test_type == 'quality-only'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Test bundle size analysis
run: |
echo "Testing bundle size analysis..."
ls -la build/client/ || echo "Build directory structure checked"
- name: Test dependency checks
run: |
echo "Testing depcheck..."
npx depcheck --config .depcheckrc.json || echo "Depcheck completed"
- name: Test package.json formatting
run: |
echo "Testing package.json sorting..."
npx sort-package-json package.json --check || echo "Package.json check completed"
validate-docker-config:
name: Validate Docker Configuration
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Test Docker build (without push)
run: |
echo "Testing Docker build configuration..."
docker build --target bolt-ai-production . --no-cache --progress=plain
echo "✅ Docker build test completed"
test-results-summary:
name: Test Results Summary
runs-on: ubuntu-latest
needs: [workflow-test-info, test-basic-setup, test-linting, test-caching, test-security-tools, test-quality-checks, validate-docker-config]
if: always()
steps:
- name: Check all test results
run: |
echo "🧪 Workflow Testing Results Summary"
echo "=================================="
if [[ "${{ needs.test-basic-setup.result }}" == "success" ]]; then
echo "✅ Basic Setup: PASSED"
else
echo "❌ Basic Setup: FAILED"
fi
if [[ "${{ needs.test-linting.result }}" == "success" ]]; then
echo "✅ Linting Tests: PASSED"
else
echo "❌ Linting Tests: FAILED"
fi
if [[ "${{ needs.test-caching.result }}" == "success" ]]; then
echo "✅ Caching Tests: PASSED"
else
echo "❌ Caching Tests: FAILED"
fi
if [[ "${{ needs.test-security-tools.result }}" == "success" ]]; then
echo "✅ Security Tools: PASSED"
else
echo "❌ Security Tools: FAILED"
fi
if [[ "${{ needs.test-quality-checks.result }}" == "success" ]]; then
echo "✅ Quality Checks: PASSED"
else
echo "❌ Quality Checks: FAILED"
fi
if [[ "${{ needs.validate-docker-config.result }}" == "success" ]]; then
echo "✅ Docker Config: PASSED"
else
echo "❌ Docker Config: FAILED"
fi
echo ""
echo "Next steps:"
echo "1. Review any failures above"
echo "2. Fix issues in workflow configurations"
echo "3. Re-test until all checks pass"
echo "4. Create PR to merge workflow improvements"

View File

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

8
.gitignore vendored
View File

@@ -25,7 +25,6 @@ dist-ssr
/.history
/.cache
/build
functions/build/
.env.local
.env
.dev.vars
@@ -40,9 +39,4 @@ modelfiles
site
# commit file ignore
app/commit.json
changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
*.md
.qodo
app/commit.json

View File

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

View File

@@ -1,20 +0,0 @@
{
"ci": {
"collect": {
"url": ["http://localhost:5173/"],
"startServerCommand": "pnpm run start",
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["warn", {"minScore": 0.8}],
"categories:accessibility": ["warn", {"minScore": 0.9}],
"categories:best-practices": ["warn", {"minScore": 0.8}],
"categories:seo": ["warn", {"minScore": 0.8}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}

2
.tool-versions Normal file
View File

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

View File

@@ -1,92 +0,0 @@
# File and Folder Locking Feature Implementation
## Overview
This implementation adds persistent file and folder locking functionality to the BoltDIY project. When a file or folder is locked, it cannot be modified by either the user or the AI until it is unlocked. All locks are scoped to the current chat/project to prevent locks from one project affecting files with matching names in other projects.
## New Files
### 1. `app/components/chat/LockAlert.tsx`
- A dedicated alert component for displaying lock-related error messages
- Features a distinctive amber/yellow color scheme and lock icon
- Provides clear instructions to the user about locked files
### 2. `app/lib/persistence/lockedFiles.ts`
- Core functionality for persisting file and folder locks in localStorage
- Provides functions for adding, removing, and retrieving locked files and folders
- Defines the lock modes: "full" (no modifications) and "scoped" (only additions allowed)
- Implements chat ID scoping to isolate locks to specific projects
### 3. `app/utils/fileLocks.ts`
- Utility functions for checking if a file or folder is locked
- Helps avoid circular dependencies between components and stores
- Provides a consistent interface for lock checking across the application
- Extracts chat ID from URL for project-specific lock scoping
## Modified Files
### 1. `app/components/chat/ChatAlert.tsx`
- Updated to use the new LockAlert component for locked file errors
- Maintains backward compatibility with other error types
### 2. `app/components/editor/codemirror/CodeMirrorEditor.tsx`
- Added checks to prevent editing of locked files
- Updated to use the new fileLocks utility
- Displays appropriate tooltips when a user attempts to edit a locked file
### 3. `app/components/workbench/EditorPanel.tsx`
- Added safety checks for unsavedFiles to prevent errors
- Improved handling of locked files in the editor panel
### 4. `app/components/workbench/FileTree.tsx`
- Added visual indicators for locked files and folders in the file tree
- Improved handling of locked files and folders in the file tree
- Added context menu options for locking and unlocking folders
### 5. `app/lib/stores/editor.ts`
- Added checks to prevent updating locked files
- Improved error handling for locked files
### 6. `app/lib/stores/files.ts`
- Added core functionality for locking and unlocking files and folders
- Implemented persistence of locked files and folders across page refreshes
- Added methods for checking if a file or folder is locked
- Added chat ID scoping to prevent locks from affecting other projects
### 7. `app/lib/stores/workbench.ts`
- Added methods for locking and unlocking files and folders
- Improved error handling for locked files and folders
- Fixed issues with alert initialization
- Added support for chat ID scoping of locks
### 8. `app/types/actions.ts`
- Added `isLockedFile` property to the ActionAlert interface
- Improved type definitions for locked file alerts
## Key Features
1. **Persistent File and Folder Locking**: Locks are stored in localStorage and persist across page refreshes
2. **Visual Indicators**: Locked files and folders are clearly marked in the UI with lock icons
3. **Improved Error Messages**: Clear, visually distinct error messages when attempting to modify locked items
4. **Lock Modes**: Support for both full locks (no modifications) and scoped locks (only additions allowed)
5. **Prevention of AI Modifications**: The AI is prevented from modifying locked files and folders
6. **Project-Specific Locks**: Locks are scoped to the current chat/project to prevent conflicts
7. **Recursive Folder Locking**: Locking a folder automatically locks all files and subfolders within it
## UI Improvements
1. **Enhanced Alert Design**: Modern, visually appealing alert design with better spacing and typography
2. **Contextual Icons**: Different icons and colors for different types of alerts
3. **Improved Error Details**: Better formatting of error details with monospace font and left border
4. **Responsive Buttons**: Better positioned and styled buttons with appropriate hover effects

View File

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

View File

@@ -1,103 +1,83 @@
# ---- build stage ----
FROM node:22-bookworm-slim AS build
ARG BASE=node:20.18.0
FROM ${BASE} AS base
WORKDIR /app
# CI-friendly env
ENV HUSKY=0
ENV CI=true
# Install dependencies (this step is cached as long as the dependencies don't change)
COPY package.json pnpm-lock.yaml ./
# Use pnpm
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN corepack enable pnpm && pnpm install
# Ensure git is available for build and runtime scripts
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Accept (optional) build-time public URL for Remix/Vite (Coolify can pass it)
ARG VITE_PUBLIC_APP_URL
ENV VITE_PUBLIC_APP_URL=${VITE_PUBLIC_APP_URL}
# Install deps efficiently
COPY package.json pnpm-lock.yaml* ./
RUN pnpm fetch
# Copy source and build
# Copy the rest of your app's source code
COPY . .
# install with dev deps (needed to build)
RUN pnpm install --offline --frozen-lockfile
# Build the Remix app (SSR + client)
RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm run build
# Expose the port the app runs on
EXPOSE 5173
# ---- production dependencies stage ----
FROM build AS prod-deps
# Production image
FROM base AS bolt-ai-production
# Keep only production deps for runtime
RUN pnpm prune --prod --ignore-scripts
# ---- production stage ----
FROM prod-deps AS bolt-ai-production
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=5173
ENV HOST=0.0.0.0
# Non-sensitive build arguments
# Define environment variables with default values or let them be overridden
ARG GROQ_API_KEY
ARG HuggingFace_API_KEY
ARG OPENAI_API_KEY
ARG ANTHROPIC_API_KEY
ARG OPEN_ROUTER_API_KEY
ARG GOOGLE_GENERATIVE_AI_API_KEY
ARG OLLAMA_API_BASE_URL
ARG TOGETHER_API_KEY
ARG TOGETHER_API_BASE_URL
ARG VITE_LOG_LEVEL=debug
ARG DEFAULT_NUM_CTX
# Set non-sensitive environment variables
ENV WRANGLER_SEND_METRICS=false \
GROQ_API_KEY=${GROQ_API_KEY} \
HuggingFace_KEY=${HuggingFace_API_KEY} \
OPENAI_API_KEY=${OPENAI_API_KEY} \
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
TOGETHER_API_KEY=${TOGETHER_API_KEY} \
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \
RUNNING_IN_DOCKER=true
# Note: API keys should be provided at runtime via docker run -e or docker-compose
# Example: docker run -e OPENAI_API_KEY=your_key_here ...
# Install curl for healthchecks and copy bindings script
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Copy built files and scripts
COPY --from=prod-deps /app/build /app/build
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=prod-deps /app/package.json /app/package.json
COPY --from=prod-deps /app/bindings.sh /app/bindings.sh
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}
# Pre-configure wrangler to disable metrics
RUN mkdir -p /root/.config/.wrangler && \
echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json
# Make bindings script executable
RUN chmod +x /app/bindings.sh
RUN npm run build
EXPOSE 5173
CMD [ "pnpm", "run", "dockerstart"]
# Healthcheck for deployment platforms
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \
CMD curl -fsS http://localhost:5173/ || exit 1
# Development image
FROM base AS bolt-ai-development
# Start using dockerstart script with Wrangler
CMD ["pnpm", "run", "dockerstart"]
# ---- development stage ----
FROM build AS development
# Non-sensitive development arguments
# Define the same environment variables for development
ARG GROQ_API_KEY
ARG HuggingFace
ARG OPENAI_API_KEY
ARG ANTHROPIC_API_KEY
ARG OPEN_ROUTER_API_KEY
ARG GOOGLE_GENERATIVE_AI_API_KEY
ARG OLLAMA_API_BASE_URL
ARG TOGETHER_API_KEY
ARG TOGETHER_API_BASE_URL
ARG VITE_LOG_LEVEL=debug
ARG DEFAULT_NUM_CTX
# Set non-sensitive environment variables for development
ENV VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \
RUNNING_IN_DOCKER=true
ENV GROQ_API_KEY=${GROQ_API_KEY} \
HuggingFace_API_KEY=${HuggingFace_API_KEY} \
OPENAI_API_KEY=${OPENAI_API_KEY} \
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
TOGETHER_API_KEY=${TOGETHER_API_KEY} \
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}
# Note: API keys should be provided at runtime via docker run -e or docker-compose
# Example: docker run -e OPENAI_API_KEY=your_key_here ...
RUN mkdir -p /app/run
CMD ["pnpm", "run", "dev", "--host"]
RUN mkdir -p ${WORKDIR}/run
CMD pnpm run dev --host

106
FAQ.md
View File

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

View File

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

601
README.md
View File

@@ -1,462 +1,210 @@
# 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, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, Groq, Cohere, Together, Perplexity, Moonshot (Kimi), Hyperbolic, GitHub Models, Amazon Bedrock, and OpenAI-like providers - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
# bolt.diy (Previously oTToDev)
-----
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more official installation instructions and additional information.
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.
-----
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/).
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more information. This documentation is still being updated after the transfer.
bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMedin) but has quickly grown into a massive community effort to build the BEST open source AI coding assistant!
## Table of Contents
## Join the community for bolt.diy!
- [Join the Community](#join-the-community)
- [Recent Major Additions](#recent-major-additions)
- [Features](#features)
- [Setup](#setup)
- [Quick Installation](#quick-installation)
- [Manual Installation](#manual-installation)
- [Configuring API Keys and Providers](#configuring-api-keys-and-providers)
- [Setup Using Git (For Developers only)](#setup-using-git-for-developers-only)
- [Available Scripts](#available-scripts)
- [Contributing](#contributing)
- [Roadmap](#roadmap)
- [FAQ](#faq)
https://thinktank.ottomator.ai
## Join the community
[Join the bolt.diy community here, in the oTTomator Think Tank!](https://thinktank.ottomator.ai)
## Requested Additions - Feel Free to Contribute!
## Project management
- ✅ OpenRouter Integration (@coleam00)
- ✅ Gemini Integration (@jonathands)
- ✅ Autogenerate Ollama models from what is downloaded (@yunatamos)
- ✅ Filter models by provider (@jasonm23)
- ✅ Download project as ZIP (@fabwaseem)
- ✅ Improvements to the main bolt.new prompt in `app\lib\.server\llm\prompts.ts` (@kofi-bhr)
- ✅ DeepSeek API Integration (@zenith110)
- ✅ Mistral API Integration (@ArulGandhi)
- ✅ "Open AI Like" API Integration (@ZerxZ)
- ✅ Ability to sync files (one way sync) to local folder (@muzafferkadir)
- ✅ Containerize the application with Docker for easy installation (@aaronbolton)
- ✅ Publish projects directly to GitHub (@goncaloalves)
- ✅ Ability to enter API keys in the UI (@ali00209)
- ✅ xAI Grok Beta Integration (@milutinke)
- ✅ LM Studio Integration (@karrot0)
- ✅ HuggingFace Integration (@ahsan3219)
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
- ✅ Streaming of code output (@thecodacus)
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
- ✅ Cohere Integration (@hasanraiyan)
- ✅ Dynamic model max token length (@hasanraiyan)
- ✅ Better prompt enhancing (@SujalXplores)
- ✅ Prompt caching (@SujalXplores)
- ✅ Load local projects into the app (@wonderwhy-er)
- ✅ Together Integration (@mouimet-infinisoft)
- ✅ Mobile friendly (@qwikode)
- ✅ Better prompt enhancing (@SujalXplores)
- ✅ Attach images to prompts (@atrokhym)
- ✅ Added Git Clone button (@thecodacus)
- ✅ Git Import from url (@thecodacus)
- ✅ PromptLibrary to have different variations of prompts for different use cases (@thecodacus)
- ✅ Detect package.json and commands to auto install & run preview for folder and git import (@wonderwhy-er)
- ✅ Selection tool to target changes visually (@emcconnell)
-**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
- ⬜ 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.
- ⬜ Voice prompting
- ⬜ Azure Open AI API Integration
- ⬜ Perplexity Integration
- ⬜ Vertex AI Integration
Bolt.diy is a community effort! Still, the core team of contributors aims at organizing the project in way that allows
you to understand where the current areas of focus are.
## bolt.diy Features
If you want to know what we are working on, what we are planning to work on, or if you want to contribute to the
project, please check the [project management guide](./PROJECT.md) to get started easily.
## Recent Major Additions
### ✅ Completed Features
- **19+ AI Provider Integrations** - OpenAI, Anthropic, Google, Groq, xAI, DeepSeek, Mistral, Cohere, Together, Perplexity, HuggingFace, Ollama, LM Studio, OpenRouter, Moonshot, Hyperbolic, GitHub Models, Amazon Bedrock, OpenAI-like
- **Electron Desktop App** - Native desktop experience with full functionality
- **Advanced Deployment Options** - Netlify, Vercel, and GitHub Pages deployment
- **Supabase Integration** - Database management and query capabilities
- **Data Visualization & Analysis** - Charts, graphs, and data analysis tools
- **MCP (Model Context Protocol)** - Enhanced AI tool integration
- **Search Functionality** - Codebase search and navigation
- **File Locking System** - Prevents conflicts during AI code generation
- **Diff View** - Visual representation of AI-made changes
- **Git Integration** - Clone, import, and deployment capabilities
- **Expo App Creation** - React Native development support
- **Voice Prompting** - Audio input for prompts
- **Bulk Chat Operations** - Delete multiple chats at once
- **Project Snapshot Restoration** - Restore projects from snapshots on reload
### 🔄 In Progress / Planned
- **File Locking & Diff Improvements** - Enhanced conflict prevention
- **Backend Agent Architecture** - Move from single model calls to agent-based system
- **LLM Prompt Optimization** - Better performance for smaller models
- **Project Planning Documentation** - LLM-generated project plans in markdown
- **VSCode Integration** - Git-like confirmations and workflows
- **Document Upload for Knowledge** - Reference materials and coding style guides
- **Additional Provider Integrations** - Azure OpenAI, Vertex AI, Granite
## Features
- **AI-powered full-stack web development** for **NodeJS based applications** directly in your browser.
- **Support for 19+ LLMs** with an extensible architecture to integrate additional models.
- **AI-powered full-stack web development** directly in your browser.
- **Support for multiple LLMs** with an extensible architecture to integrate additional models.
- **Attach images to prompts** for better contextual understanding.
- **Integrated terminal** to view output of LLM-run commands.
- **Revert code to earlier versions** for easier debugging and quicker changes.
- **Download projects as ZIP** for easy portability and sync to a folder on the host.
- **Download projects as ZIP** for easy portability.
- **Integration-ready Docker support** for a hassle-free setup.
- **Deploy directly** to **Netlify**, **Vercel**, or **GitHub Pages**.
- **Electron desktop app** for native desktop experience.
- **Data visualization and analysis** with integrated charts and graphs.
- **Git integration** with clone, import, and deployment capabilities.
- **MCP (Model Context Protocol)** support for enhanced AI tool integration.
- **Search functionality** to search through your codebase.
- **File locking system** to prevent conflicts during AI code generation.
- **Diff view** to see changes made by the AI.
- **Supabase integration** for database management and queries.
- **Expo app creation** for React Native development.
## Setup
## Setup bolt.diy
If you're new to installing software from GitHub, don't worry! If you encounter any issues, feel free to submit an "issue" using the provided links or improve this documentation by forking the repository, editing the instructions, and submitting a pull request. The following instruction will help you get the stable branch up and running on your local machine in no time.
If you're new to installing software from GitHub, don't worry! If you encounter any issues, feel free to submit an "issue" using the provided links or improve this documentation by forking the repository, editing the instructions, and submitting a pull request. The following instruction will help you get the stable branch up and running on your local machine in no time.
Let's get you up and running with the stable version of Bolt.DIY!
### Prerequisites
## Quick Installation
1. **Install Git**: [Download Git](https://git-scm.com/downloads)
2. **Install Node.js**: [Download Node.js](https://nodejs.org/en/download/)
[![Download Latest Release](https://img.shields.io/github/v/release/stackblitz-labs/bolt.diy?label=Download%20Bolt&sort=semver)](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go to the latest release version!
- After installation, the Node.js path is usually added to your system automatically. To verify:
- **Windows**: Search for "Edit the system environment variables," click "Environment Variables," and check if `Node.js` is in the `Path` variable.
- **Mac/Linux**: Open a terminal and run:
```bash
echo $PATH
```
Look for `/usr/local/bin` in the output.
- Download the binary for your platform (available for Windows, macOS, and Linux)
- **Note**: For macOS, if you get the error "This app is damaged", run:
```bash
xattr -cr /path/to/Bolt.app
```
### Clone the Repository
## Manual installation
Clone the repository using Git:
```bash
git clone -b stable https://github.com/stackblitz-labs/bolt.diy
```
### Option 1: Node.js
### (Optional) Configure Environment Variables
Node.js is required to run the application.
Most environment variables can be configured directly through the settings menu of the application. However, if you need to manually configure them:
1. Visit the [Node.js Download Page](https://nodejs.org/en/download/)
2. Download the "LTS" (Long Term Support) version for your operating system
3. Run the installer, accepting the default settings
4. Verify Node.js is properly installed:
- **For Windows Users**:
1. Press `Windows + R`
2. Type "sysdm.cpl" and press Enter
3. Go to "Advanced" tab → "Environment Variables"
4. Check if `Node.js` appears in the "Path" variable
- **For Mac/Linux Users**:
1. Open Terminal
2. Type this command:
```bash
echo $PATH
```
3. Look for `/usr/local/bin` in the output
1. Rename `.env.example` to `.env.local`.
2. Add your LLM API keys. For example:
## Running the Application
```env
GROQ_API_KEY=YOUR_GROQ_API_KEY
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
```
You have two options for running Bolt.DIY: directly on your machine or using Docker.
**Note**: Ollama does not require an API key as it runs locally.
### Option 1: Direct Installation (Recommended for Beginners)
3. Optionally, set additional configurations:
1. **Install Package Manager (pnpm)**:
```env
# Debugging
VITE_LOG_LEVEL=debug
# Ollama settings (example: 8K context, localhost port 11434)
OLLAMA_API_BASE_URL=http://localhost:11434
DEFAULT_NUM_CTX=8192
```
**Important**: Do not commit your `.env.local` file to version control. This file is already included in `.gitignore`.
---
## Run the Application
### Option 1: Without Docker
1. **Install Dependencies**:
```bash
pnpm install
```
If `pnpm` is not installed, install it using:
```bash
sudo npm install -g pnpm
```
2. **Start the Application**:
```bash
pnpm run dev
```
This will start the Remix Vite development server. You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway.
### Option 2: With Docker
#### Prerequisites
- Ensure Git, Node.js, and Docker are installed: [Download Docker](https://www.docker.com/)
#### Steps
1. **Build the Docker Image**:
Use the provided NPM scripts:
```bash
npm run dockerbuild # Development build
npm run dockerbuild:prod # Production build
```
Alternatively, use Docker commands directly:
```bash
docker build . --target bolt-ai-development # Development build
docker build . --target bolt-ai-production # Production build
```
2. **Run the Container**:
Use Docker Compose profiles to manage environments:
```bash
docker-compose --profile development up # Development
docker-compose --profile production up # Production
```
- With the development profile, changes to your code will automatically reflect in the running container (hot reloading).
---
### Update Your Local Version to the Latest
To keep your local version of bolt.diy up to date with the latest changes, follow these steps for your operating system:
#### 1. **Navigate to your project folder**
Navigate to the directory where you cloned the repository and open a terminal:
#### 2. **Fetch the Latest Changes**
Use Git to pull the latest changes from the main repository:
```bash
npm install -g pnpm
git pull origin main
```
2. **Install Project Dependencies**:
#### 3. **Update Dependencies**
After pulling the latest changes, update the project dependencies by running the following command:
```bash
pnpm install
```
3. **Start the Application**:
#### 4. **Run the Application**
Once the updates are complete, you can start the application again with:
```bash
pnpm run dev
```
### Option 2: Using Docker
This option requires Docker and is great when you want an isolated environment or to mirror the production image.
#### Additional Prerequisite
- Install Docker: [Download Docker](https://www.docker.com/)
#### Steps
1. **Prepare Environment Variables**
Copy the provided examples and add your provider keys:
```bash
cp .env.example .env
cp .env.example .env.local
```
The runtime scripts inside the container source `.env` and `.env.local`, so keep any API keys you need in one of those files.
2. **Build an Image**
```bash
# Development image (bind-mounts your local source when run)
pnpm run dockerbuild
# ≈ docker build -t bolt-ai:development -t bolt-ai:latest --target development .
# Production image (self-contained build artifacts)
pnpm run dockerbuild:prod
# ≈ docker build -t bolt-ai:production -t bolt-ai:latest --target bolt-ai-production .
```
3. **Run the Container**
```bash
# Development workflow with hot reload
docker compose --profile development up
# Production-style container using composed services
docker compose --profile production up
# One-off production container (exposes the app on port 5173)
docker run --rm -p 5173:5173 --env-file .env.local bolt-ai:latest
```
When the container starts it runs `pnpm run dockerstart`, which in turn executes `bindings.sh` to pass Cloudflare bindings through Wrangler. You can override this command in `docker-compose.yaml` if you need a different startup routine.
### Option 3: Desktop Application (Electron)
For users who prefer a native desktop experience, bolt.diy is also available as an Electron desktop application:
1. **Download the Desktop App**:
- Visit the [latest release](https://github.com/stackblitz-labs/bolt.diy/releases/latest)
- Download the appropriate binary for your operating system
- For macOS: Extract and run the `.dmg` file
- For Windows: Run the `.exe` installer
- For Linux: Extract and run the AppImage or install the `.deb` package
2. **Alternative**: Build from Source:
```bash
# Install dependencies
pnpm install
# Build the Electron app
pnpm electron:build:dist # For all platforms
# OR platform-specific:
pnpm electron:build:mac # macOS
pnpm electron:build:win # Windows
pnpm electron:build:linux # Linux
```
The desktop app provides the same full functionality as the web version with additional native features.
## Configuring API Keys and Providers
Bolt.diy features a modern, intuitive settings interface for managing AI providers and API keys. The settings are organized into dedicated panels for easy navigation and configuration.
### Accessing Provider Settings
1. **Open Settings**: Click the settings icon (⚙️) in the sidebar to access the settings panel
2. **Navigate to Providers**: Select the "Providers" tab from the settings menu
3. **Choose Provider Type**: Switch between "Cloud Providers" and "Local Providers" tabs
### Cloud Providers Configuration
The Cloud Providers tab displays all cloud-based AI services in an organized card layout:
#### Adding API Keys
1. **Select Provider**: Browse the grid of available cloud providers (OpenAI, Anthropic, Google, etc.)
2. **Toggle Provider**: Use the switch to enable/disable each provider
3. **Set API Key**:
- Click the provider card to expand its configuration
- Click on the "API Key" field to enter edit mode
- Paste your API key and press Enter to save
- The interface shows real-time validation with green checkmarks for valid keys
#### Advanced Features
- **Bulk Toggle**: Use "Enable All Cloud" to toggle all cloud providers at once
- **Visual Status**: Green checkmarks indicate properly configured providers
- **Provider Icons**: Each provider has a distinctive icon for easy identification
- **Descriptions**: Helpful descriptions explain each provider's capabilities
### Local Providers Configuration
The Local Providers tab manages local AI installations and custom endpoints:
#### Ollama Configuration
1. **Enable Ollama**: Toggle the Ollama provider switch
2. **Configure Endpoint**: Set the API endpoint (defaults to `http://127.0.0.1:11434`)
3. **Model Management**:
- View all installed models with size and parameter information
- Update models to latest versions with one click
- Delete unused models
- Install new models by entering model names
#### Other Local Providers
- **LM Studio**: Configure custom base URLs for LM Studio endpoints
- **OpenAI-like**: Connect to any OpenAI-compatible API endpoint
- **Auto-detection**: The system automatically detects environment variables for base URLs
### Environment Variables vs UI Configuration
Bolt.diy supports both methods for maximum flexibility:
#### Environment Variables (Recommended for Production)
Set API keys and base URLs in your `.env.local` file:
```bash
# API Keys
OPENAI_API_KEY=your_openai_key_here
ANTHROPIC_API_KEY=your_anthropic_key_here
# Custom Base URLs
OLLAMA_BASE_URL=http://127.0.0.1:11434
LMSTUDIO_BASE_URL=http://127.0.0.1:1234
```
#### UI-Based Configuration
- **Real-time Updates**: Changes take effect immediately
- **Secure Storage**: API keys are stored securely in browser cookies
- **Visual Feedback**: Clear indicators show configuration status
- **Easy Management**: Edit, view, and manage keys through the interface
### Provider-Specific Features
#### OpenRouter
- **Free Models Filter**: Toggle to show only free models when browsing
- **Pricing Information**: View input/output costs for each model
- **Model Search**: Fuzzy search through all available models
#### Ollama
- **Model Installer**: Built-in interface to install new models
- **Progress Tracking**: Real-time download progress for model updates
- **Model Details**: View model size, parameters, and quantization levels
- **Auto-refresh**: Automatically detects newly installed models
#### Search & Navigation
- **Fuzzy Search**: Type-ahead search across all providers and models
- **Keyboard Navigation**: Use arrow keys and Enter to navigate quickly
- **Clear Search**: Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to clear search
### Troubleshooting
#### Common Issues
- **API Key Not Recognized**: Ensure you're using the correct API key format for each provider
- **Base URL Issues**: Verify the endpoint URL is correct and accessible
- **Model Not Loading**: Check that the provider is enabled and properly configured
- **Environment Variables Not Working**: Restart the application after adding new environment variables
#### Status Indicators
- 🟢 **Green Checkmark**: Provider properly configured and ready to use
- 🔴 **Red X**: Configuration missing or invalid
- 🟡 **Yellow Indicator**: Provider enabled but may need additional setup
- 🔵 **Blue Pencil**: Click to edit configuration
### Supported Providers Overview
#### Cloud Providers
- **OpenAI** - GPT-4, GPT-3.5, and other OpenAI models
- **Anthropic** - Claude 3.5 Sonnet, Claude 3 Opus, and other Claude models
- **Google (Gemini)** - Gemini 1.5 Pro, Gemini 1.5 Flash, and other Gemini models
- **Groq** - Fast inference with Llama, Mixtral, and other models
- **xAI** - Grok models including Grok-2 and Grok-2 Vision
- **DeepSeek** - DeepSeek Coder and other DeepSeek models
- **Mistral** - Mixtral, Mistral 7B, and other Mistral models
- **Cohere** - Command R, Command R+, and other Cohere models
- **Together AI** - Various open-source models
- **Perplexity** - Sonar models for search and reasoning
- **HuggingFace** - Access to HuggingFace model hub
- **OpenRouter** - Unified API for multiple model providers
- **Moonshot (Kimi)** - Kimi AI models
- **Hyperbolic** - High-performance model inference
- **GitHub Models** - Models available through GitHub
- **Amazon Bedrock** - AWS managed AI models
#### Local Providers
- **Ollama** - Run open-source models locally with advanced model management
- **LM Studio** - Local model inference with LM Studio
- **OpenAI-like** - Connect to any OpenAI-compatible API endpoint
> **💡 Pro Tip**: Start with OpenAI or Anthropic for the best results, then explore other providers based on your specific needs and budget considerations.
## Setup Using Git (For Developers only)
This method is recommended for developers who want to:
- Contribute to the project
- Stay updated with the latest changes
- Switch between different versions
- Create custom modifications
#### Prerequisites
1. Install Git: [Download Git](https://git-scm.com/downloads)
#### Initial Setup
1. **Clone the Repository**:
```bash
git clone -b stable https://github.com/stackblitz-labs/bolt.diy.git
```
2. **Navigate to Project Directory**:
```bash
cd bolt.diy
```
3. **Install Dependencies**:
```bash
pnpm install
```
4. **Start the Development Server**:
```bash
pnpm run dev
```
5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch:
```bash
git checkout main
pnpm install
pnpm run dev
```
Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
>**Open the WebUI to test (Default: http://localhost:5173)**
> - Beginners:
> - Try to use a sophisticated Provider/Model like Anthropic with Claude Sonnet 3.x Models to get best results
> - Explanation: The System Prompt currently implemented in bolt.diy cant cover the best performance for all providers and models out there. So it works better with some models, then other, even if the models itself are perfect for >programming
> - Future: Planned is a Plugin/Extentions-Library so there can be different System Prompts for different Models, which will help to get better results
#### Staying Updated
To get the latest changes from the repository:
1. **Save Your Local Changes** (if any):
```bash
git stash
```
2. **Pull Latest Updates**:
```bash
git pull
```
3. **Update Dependencies**:
```bash
pnpm install
```
4. **Restore Your Local Changes** (if any):
```bash
git stash pop
```
#### Troubleshooting Git Setup
If you encounter issues:
1. **Clean Installation**:
```bash
# Remove node modules and lock files
rm -rf node_modules pnpm-lock.yaml
# Clear pnpm cache
pnpm store prune
# Reinstall dependencies
pnpm install
```
2. **Reset Local Changes**:
```bash
# Discard all local changes
git reset --hard origin/main
```
Remember to always commit your local changes or stash them before pulling updates to avoid conflicts.
This ensures that you're running the latest version of bolt.diy and can take advantage of all the newest features and bug fixes.
---
@@ -470,25 +218,7 @@ Remember to always commit your local changes or stash them before pulling update
- **`pnpm run typecheck`**: Runs TypeScript type checking.
- **`pnpm run typegen`**: Generates TypeScript types using Wrangler.
- **`pnpm run deploy`**: Deploys the project to Cloudflare Pages.
- **`pnpm run lint`**: Runs ESLint to check for code issues.
- **`pnpm run lint:fix`**: Automatically fixes linting issues.
- **`pnpm run clean`**: Cleans build artifacts and cache.
- **`pnpm run prepare`**: Sets up husky for git hooks.
- **Docker Scripts**:
- **`pnpm run dockerbuild`**: Builds the Docker image for development.
- **`pnpm run dockerbuild:prod`**: Builds the Docker image for production.
- **`pnpm run dockerrun`**: Runs the Docker container.
- **`pnpm run dockerstart`**: Starts the Docker container with proper bindings.
- **Electron Scripts**:
- **`pnpm electron:build:deps`**: Builds Electron main and preload scripts.
- **`pnpm electron:build:main`**: Builds the Electron main process.
- **`pnpm electron:build:preload`**: Builds the Electron preload script.
- **`pnpm electron:build:renderer`**: Builds the Electron renderer.
- **`pnpm electron:build:unpack`**: Creates an unpacked Electron build.
- **`pnpm electron:build:mac`**: Builds for macOS.
- **`pnpm electron:build:win`**: Builds for Windows.
- **`pnpm electron:build:linux`**: Builds for Linux.
- **`pnpm electron:build:dist`**: Builds for all platforms.
---
@@ -506,11 +236,4 @@ 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.
# Test commit to trigger Security Analysis workflow
For answers to common questions, visit our [FAQ Page](FAQ.md).

1
app/commit.json Normal file
View File

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

View File

@@ -1,175 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, Profile } from './types';
interface AvatarDropdownProps {
onSelectTab: (tab: TabType) => void;
}
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
const profile = useStore(profileStore) as Profile;
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<motion.button
className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className="w-full h-full rounded-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
<div className="i-ph:user w-6 h-6" />
</div>
)}
</motion.button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<div
className={classNames(
'px-4 py-3 flex items-center gap-3',
'border-b border-gray-200/50 dark:border-gray-800/50',
)}
>
<div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
loading="eager"
decoding="sync"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
<div className="i-ph:user w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
{profile?.username || 'Guest User'}
</div>
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
</div>
</div>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('profile')}
>
<div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Edit Profile
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('settings')}
>
<div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Settings
</DropdownMenu.Item>
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() =>
window.open('https://github.com/stackblitz-labs/bolt.diy/issues/new?template=bug_report.yml', '_blank')
}
>
<div className="i-ph:bug w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Report Bug
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={async () => {
try {
const { downloadDebugLog } = await import('~/utils/debugLogger');
await downloadDebugLog();
} catch (error) {
console.error('Failed to download debug log:', error);
}
}}
>
<div className="i-ph:download w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Download Debug Log
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => window.open('https://stackblitz-labs.github.io/bolt.diy/', '_blank')}
>
<div className="i-ph:question w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Help & Documentation
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -1,345 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useStore } from '@nanostores/react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { TabTile } from '~/components/@settings/shared/components/TabTile';
import { useFeatures } from '~/lib/hooks/useFeatures';
import { useNotifications } from '~/lib/hooks/useNotifications';
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
import { tabConfigurationStore, resetTabConfiguration } from '~/lib/stores/settings';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, Profile } from './types';
import { TAB_LABELS, DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants';
import { DialogTitle } from '~/components/ui/Dialog';
import { AvatarDropdown } from './AvatarDropdown';
import BackgroundRays from '~/components/ui/BackgroundRays';
// Import all tab components
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
import { DataTab } from '~/components/@settings/tabs/data/DataTab';
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
import GitHubTab from '~/components/@settings/tabs/github/GitHubTab';
import GitLabTab from '~/components/@settings/tabs/gitlab/GitLabTab';
import SupabaseTab from '~/components/@settings/tabs/supabase/SupabaseTab';
import VercelTab from '~/components/@settings/tabs/vercel/VercelTab';
import NetlifyTab from '~/components/@settings/tabs/netlify/NetlifyTab';
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
import McpTab from '~/components/@settings/tabs/mcp/McpTab';
interface ControlPanelProps {
open: boolean;
onClose: () => void;
}
// Beta status for experimental features
const BETA_TABS = new Set<TabType>(['local-providers', 'mcp']);
const BetaLabel = () => (
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
<span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
</div>
);
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
// State
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
// Store values
const tabConfiguration = useStore(tabConfigurationStore);
const profile = useStore(profileStore) as Profile;
// Status hooks
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
// Memoize the base tab configurations to avoid recalculation
const baseTabConfig = useMemo(() => {
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
}, []);
// Add visibleTabs logic using useMemo with optimized calculations
const visibleTabs = useMemo(() => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, resetting to defaults');
resetTabConfiguration();
return [];
}
const notificationsDisabled = profile?.preferences?.notifications === false;
// Optimize user mode tab filtering
return tabConfiguration.userTabs
.filter((tab) => {
if (!tab?.id) {
return false;
}
if (tab.id === 'notifications' && notificationsDisabled) {
return false;
}
return tab.visible && tab.window === 'user';
})
.sort((a, b) => a.order - b.order);
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
// Reset to default view when modal opens/closes
useEffect(() => {
if (!open) {
// Reset when closing
setActiveTab(null);
setLoadingTab(null);
setShowTabManagement(false);
} else {
// When opening, set to null to show the main view
setActiveTab(null);
}
}, [open]);
// Handle closing
const handleClose = () => {
setActiveTab(null);
setLoadingTab(null);
setShowTabManagement(false);
onClose();
};
// Handlers
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
const getTabComponent = (tabId: TabType) => {
switch (tabId) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'github':
return <GitHubTab />;
case 'gitlab':
return <GitLabTab />;
case 'supabase':
return <SupabaseTab />;
case 'vercel':
return <VercelTab />;
case 'netlify':
return <NetlifyTab />;
case 'event-logs':
return <EventLogsTab />;
case 'mcp':
return <McpTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'github':
case 'gitlab':
case 'supabase':
case 'vercel':
case 'netlify':
return hasConnectionIssues;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'github':
case 'gitlab':
case 'supabase':
case 'vercel':
case 'netlify':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
default:
return '';
}
};
const handleTabClick = (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
setShowTabManagement(false);
// Acknowledge notifications based on tab
switch (tabId) {
case 'features':
acknowledgeAllFeatures();
break;
case 'notifications':
markAllAsRead();
break;
case 'github':
case 'gitlab':
case 'supabase':
case 'vercel':
case 'netlify':
acknowledgeIssue();
break;
}
// Clear loading state after a delay
setTimeout(() => setLoadingTab(null), 500);
};
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
<RadixDialog.Overlay className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm transition-opacity duration-200" />
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
className="relative z-[101]"
>
<div
className={classNames(
'w-[1200px] h-[90vh]',
'bg-bolt-elements-background-depth-1',
'rounded-2xl shadow-2xl',
'border border-bolt-elements-borderColor',
'flex flex-col overflow-hidden',
'relative',
'transform transition-all duration-200 ease-out',
open ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-4',
)}
>
<div className="absolute inset-0 overflow-hidden rounded-2xl">
<BackgroundRays />
</div>
<div className="relative z-10 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{(activeTab || showTabManagement) && (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center gap-6">
{/* Avatar and Dropdown */}
<div className="pl-6">
<AvatarDropdown onSelectTab={handleTabClick} />
</div>
{/* Close Button */}
<button
onClick={handleClose}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<div
className={classNames(
'p-6 transition-opacity duration-150',
activeTab || showTabManagement ? 'opacity-100' : 'opacity-100',
)}
>
{activeTab ? (
getTabComponent(activeTab)
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
{visibleTabs.map((tab, index) => (
<div
key={tab.id}
className={classNames(
'aspect-[1.5/1] transition-transform duration-100 ease-out',
'hover:scale-[1.01]',
)}
style={{
animationDelay: `${index * 30}ms`,
animation: open ? 'fadeInUp 200ms ease-out forwards' : 'none',
}}
>
<TabTile
tab={tab}
onClick={() => handleTabClick(tab.id as TabType)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
className="h-full relative"
>
{BETA_TABS.has(tab.id) && <BetaLabel />}
</TabTile>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@@ -1,108 +0,0 @@
import type { TabType } from './types';
import { User, Settings, Bell, Star, Database, Cloud, Laptop, Github, Wrench, List } from 'lucide-react';
// GitLab icon component
const GitLabIcon = () => (
<svg viewBox="0 0 24 24" className="w-4 h-4">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
);
// Vercel icon component
const VercelIcon = () => (
<svg viewBox="0 0 24 24" className="w-4 h-4">
<path fill="currentColor" d="M12 2L2 19.777h20L12 2z" />
</svg>
);
// Netlify icon component
const NetlifyIcon = () => (
<svg viewBox="0 0 24 24" className="w-4 h-4">
<path
fill="currentColor"
d="M16.934 8.519a1.044 1.044 0 0 1 .303-.23l2.349-1.045a.983.983 0 0 1 .905 0c.264.12.49.328.651.599l.518 1.065c.17.35.17.761 0 1.11l-.518 1.065a1.119 1.119 0 0 1-.651.599l-2.35 1.045a1.013 1.013 0 0 1-.904 0l-2.35-1.045a1.119 1.119 0 0 1-.651-.599L13.718 9.02a1.2 1.2 0 0 1 0-1.11l.518-1.065a1.119 1.119 0 0 1 .651-.599l2.35-1.045a.983.983 0 0 1 .697-.061zm-6.051 5.751a1.044 1.044 0 0 1 .303-.23l2.349-1.045a.983.983 0 0 1 .905 0c.264.12.49.328.651.599l.518 1.065c.17.35.17.761 0 1.11l-.518 1.065a1.119 1.119 0 0 1-.651.599l-2.35 1.045a1.013 1.013 0 0 1-.904 0l-2.35-1.045a1.119 1.119 0 0 1-.651-.599l-.518-1.065a1.2 1.2 0 0 1 0-1.11l.518-1.065a1.119 1.119 0 0 1 .651-.599l2.35-1.045a.983.983 0 0 1 .697-.061z"
/>
</svg>
);
// Supabase icon component
const SupabaseIcon = () => (
<svg viewBox="0 0 24 24" className="w-4 h-4">
<path
fill="currentColor"
d="M21.362 9.354H12V.396a.396.396 0 0 0-.716-.233L2.203 12.424l-.401.562a1.04 1.04 0 0 0 .836 1.659H12V21.6a.396.396 0 0 0 .716.233l9.081-12.261.401-.562a1.04 1.04 0 0 0-.836-1.656z"
/>
</svg>
);
export const TAB_ICONS: Record<TabType, React.ComponentType<{ className?: string }>> = {
profile: User,
settings: Settings,
notifications: Bell,
features: Star,
data: Database,
'cloud-providers': Cloud,
'local-providers': Laptop,
github: Github,
gitlab: () => <GitLabIcon />,
netlify: () => <NetlifyIcon />,
vercel: () => <VercelIcon />,
supabase: () => <SupabaseIcon />,
'event-logs': List,
mcp: Wrench,
};
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
github: 'GitHub',
gitlab: 'GitLab',
netlify: 'Netlify',
vercel: 'Vercel',
supabase: 'Supabase',
'event-logs': 'Event Logs',
mcp: 'MCP Servers',
};
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
github: 'Connect and manage GitHub integration',
gitlab: 'Connect and manage GitLab integration',
netlify: 'Configure Netlify deployment settings',
vercel: 'Manage Vercel projects and deployments',
supabase: 'Setup Supabase database connection',
'event-logs': 'View system events and logs',
mcp: 'Configure MCP (Model Context Protocol) servers',
};
export const DEFAULT_TAB_CONFIG = [
// User Window Tabs (Always visible by default)
{ id: 'features', visible: true, window: 'user' as const, order: 0 },
{ id: 'data', visible: true, window: 'user' as const, order: 1 },
{ id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
{ id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
{ id: 'github', visible: true, window: 'user' as const, order: 4 },
{ id: 'gitlab', visible: true, window: 'user' as const, order: 5 },
{ id: 'netlify', visible: true, window: 'user' as const, order: 6 },
{ id: 'vercel', visible: true, window: 'user' as const, order: 7 },
{ id: 'supabase', visible: true, window: 'user' as const, order: 8 },
{ id: 'notifications', visible: true, window: 'user' as const, order: 9 },
{ id: 'event-logs', visible: true, window: 'user' as const, order: 10 },
{ id: 'mcp', visible: true, window: 'user' as const, order: 11 },
// User Window Tabs (In dropdown, initially hidden)
];

View File

@@ -1,114 +0,0 @@
import type { ReactNode } from 'react';
import { User, Folder, Wifi, Settings, Box, Sliders } from 'lucide-react';
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
export type TabType =
| 'profile'
| 'settings'
| 'notifications'
| 'features'
| 'data'
| 'cloud-providers'
| 'local-providers'
| 'github'
| 'gitlab'
| 'netlify'
| 'vercel'
| 'supabase'
| 'event-logs'
| 'mcp';
export type WindowType = 'user' | 'developer';
export interface UserProfile {
nickname: any;
name: string;
email: string;
avatar?: string;
theme: 'light' | 'dark' | 'system';
notifications: boolean;
password?: string;
bio?: string;
language: string;
timezone: string;
}
export interface SettingItem {
id: TabType;
label: string;
icon: string;
category: SettingCategory;
description?: string;
component: () => ReactNode;
badge?: string;
keywords?: string[];
}
export interface TabVisibilityConfig {
id: TabType;
visible: boolean;
window: WindowType;
order: number;
isExtraDevTab?: boolean;
locked?: boolean;
}
export interface DevTabConfig extends TabVisibilityConfig {
window: 'developer';
}
export interface UserTabConfig extends TabVisibilityConfig {
window: 'user';
}
export interface TabWindowConfig {
userTabs: UserTabConfig[];
}
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
github: 'GitHub',
gitlab: 'GitLab',
netlify: 'Netlify',
vercel: 'Vercel',
supabase: 'Supabase',
'event-logs': 'Event Logs',
mcp: 'MCP Servers',
};
export const categoryLabels: Record<SettingCategory, string> = {
profile: 'Profile & Account',
file_sharing: 'File Sharing',
connectivity: 'Connectivity',
system: 'System',
services: 'Services',
preferences: 'Preferences',
};
export const categoryIcons: Record<SettingCategory, React.ComponentType<{ className?: string }>> = {
profile: User,
file_sharing: Folder,
connectivity: Wifi,
system: Settings,
services: Box,
preferences: Sliders,
};
export interface Profile {
username?: string;
bio?: string;
avatar?: string;
preferences?: {
notifications?: boolean;
theme?: 'light' | 'dark' | 'system';
language?: string;
timezone?: string;
};
}

View File

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

View File

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

View File

@@ -1,193 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
interface TokenTypeOption {
value: string;
label: string;
description?: string;
}
interface ConnectionFormProps {
isConnected: boolean;
isConnecting: boolean;
token: string;
onTokenChange: (token: string) => void;
onConnect: (e: React.FormEvent) => void;
onDisconnect: () => void;
error?: string;
serviceName: string;
tokenLabel?: string;
tokenPlaceholder?: string;
getTokenUrl: string;
environmentVariable?: string;
tokenTypes?: TokenTypeOption[];
selectedTokenType?: string;
onTokenTypeChange?: (type: string) => void;
connectedMessage?: string;
children?: React.ReactNode; // For additional form fields
}
export function ConnectionForm({
isConnected,
isConnecting,
token,
onTokenChange,
onConnect,
onDisconnect,
error,
serviceName,
tokenLabel = 'Access Token',
tokenPlaceholder,
getTokenUrl,
environmentVariable,
tokenTypes,
selectedTokenType,
onTokenTypeChange,
connectedMessage = `Connected to ${serviceName}`,
children,
}: ConnectionFormProps) {
return (
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
{!isConnected ? (
<div className="space-y-4">
{environmentVariable && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
{environmentVariable}
</code>{' '}
environment variable to connect automatically.
</p>
</div>
)}
<form onSubmit={onConnect} className="space-y-4">
{tokenTypes && tokenTypes.length > 1 && onTokenTypeChange && (
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
Token Type
</label>
<select
value={selectedTokenType}
onChange={(e) => onTokenTypeChange(e.target.value)}
disabled={isConnecting}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
'disabled:opacity-50',
)}
>
{tokenTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
{selectedTokenType && tokenTypes.find((t) => t.value === selectedTokenType)?.description && (
<p className="mt-1 text-xs text-bolt-elements-textTertiary">
{tokenTypes.find((t) => t.value === selectedTokenType)?.description}
</p>
)}
</div>
)}
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">{tokenLabel}</label>
<input
type="password"
value={token}
onChange={(e) => onTokenChange(e.target.value)}
disabled={isConnecting}
placeholder={tokenPlaceholder || `Enter your ${serviceName} access token`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={getTokenUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
{children}
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</form>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={onDisconnect}
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" />
{connectedMessage}
</span>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
export interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
interface ConnectionTestIndicatorProps {
testResult: ConnectionTestResult | null;
className?: string;
}
export function ConnectionTestIndicator({ testResult, className }: ConnectionTestIndicatorProps) {
if (!testResult) {
return null;
}
return (
<motion.div
className={classNames(
'p-4 rounded-lg border',
{
'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700': testResult.status === 'success',
'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700': testResult.status === 'error',
'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700': testResult.status === 'testing',
},
className,
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-center gap-2">
{testResult.status === 'success' && (
<div className="i-ph:check-circle w-5 h-5 text-green-600 dark:text-green-400" />
)}
{testResult.status === 'error' && (
<div className="i-ph:warning-circle w-5 h-5 text-red-600 dark:text-red-400" />
)}
{testResult.status === 'testing' && (
<div className="i-ph:spinner-gap w-5 h-5 animate-spin text-blue-600 dark:text-blue-400" />
)}
<span
className={classNames('text-sm font-medium', {
'text-green-800 dark:text-green-200': testResult.status === 'success',
'text-red-800 dark:text-red-200': testResult.status === 'error',
'text-blue-800 dark:text-blue-200': testResult.status === 'testing',
})}
>
{testResult.message}
</span>
</div>
{testResult.timestamp && (
<p className="text-xs text-gray-500 mt-1">{new Date(testResult.timestamp).toLocaleString()}</p>
)}
</motion.div>
);
}

View File

@@ -1,102 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
import type { ServiceError } from '~/lib/utils/serviceErrorHandler';
interface ErrorStateProps {
error?: ServiceError | string;
title?: string;
onRetry?: () => void;
onDismiss?: () => void;
retryLabel?: string;
className?: string;
showDetails?: boolean;
}
export function ErrorState({
error,
title = 'Something went wrong',
onRetry,
onDismiss,
retryLabel = 'Try again',
className,
showDetails = false,
}: ErrorStateProps) {
const errorMessage = typeof error === 'string' ? error : error?.message || 'An unknown error occurred';
const isServiceError = typeof error === 'object' && error !== null;
return (
<motion.div
className={classNames(
'p-6 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/20',
className,
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-start gap-3">
<div className="i-ph:warning-circle w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200 mb-1">{title}</h3>
<p className="text-sm text-red-700 dark:text-red-300">{errorMessage}</p>
{showDetails && isServiceError && error.details && (
<details className="mt-3">
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer hover:underline">
Technical details
</summary>
<pre className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 p-2 rounded overflow-auto">
{JSON.stringify(error.details, null, 2)}
</pre>
</details>
)}
<div className="flex items-center gap-2 mt-4">
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size="sm"
className="text-red-700 border-red-300 hover:bg-red-100 dark:text-red-300 dark:border-red-600 dark:hover:bg-red-900/30"
>
<div className="i-ph:arrows-clockwise w-4 h-4 mr-1" />
{retryLabel}
</Button>
)}
{onDismiss && (
<Button
onClick={onDismiss}
variant="outline"
size="sm"
className="text-red-700 border-red-300 hover:bg-red-100 dark:text-red-300 dark:border-red-600 dark:hover:bg-red-900/30"
>
Dismiss
</Button>
)}
</div>
</div>
</div>
</motion.div>
);
}
interface ConnectionErrorProps {
service: string;
error: ServiceError | string;
onRetryConnection: () => void;
onClearError?: () => void;
}
export function ConnectionError({ service, error, onRetryConnection, onClearError }: ConnectionErrorProps) {
return (
<ErrorState
error={error}
title={`Failed to connect to ${service}`}
onRetry={onRetryConnection}
onDismiss={onClearError}
retryLabel="Retry connection"
showDetails={true}
/>
);
}

View File

@@ -1,94 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
interface LoadingStateProps {
message?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
showProgress?: boolean;
progress?: number;
}
export function LoadingState({
message = 'Loading...',
size = 'md',
className,
showProgress = false,
progress = 0,
}: LoadingStateProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
return (
<motion.div
className={classNames('flex flex-col items-center justify-center gap-3', className)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="flex items-center gap-2">
<div
className={classNames(
'i-ph:spinner-gap animate-spin text-bolt-elements-item-contentAccent',
sizeClasses[size],
)}
/>
<span className="text-bolt-elements-textSecondary">{message}</span>
</div>
{showProgress && (
<div className="w-full max-w-xs">
<div className="w-full bg-bolt-elements-background-depth-2 rounded-full h-1">
<motion.div
className="bg-bolt-elements-item-contentAccent h-1 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
)}
</motion.div>
);
}
interface SkeletonProps {
className?: string;
lines?: number;
}
export function Skeleton({ className, lines = 1 }: SkeletonProps) {
return (
<div className={classNames('animate-pulse', className)}>
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded',
i === lines - 1 ? 'h-4' : 'h-4 mb-2',
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full',
)}
/>
))}
</div>
);
}
interface ServiceLoadingProps {
serviceName: string;
operation: string;
progress?: number;
}
export function ServiceLoading({ serviceName, operation, progress }: ServiceLoadingProps) {
return (
<LoadingState
message={`${operation} ${serviceName}...`}
showProgress={progress !== undefined}
progress={progress}
/>
);
}

View File

@@ -1,72 +0,0 @@
import React, { memo } from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
interface ServiceHeaderProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
onTestConnection?: () => void;
isTestingConnection?: boolean;
additionalInfo?: React.ReactNode;
delay?: number;
}
export const ServiceHeader = memo(
({
icon: Icon, // eslint-disable-line @typescript-eslint/naming-convention
title,
description,
onTestConnection,
isTestingConnection,
additionalInfo,
delay = 0.1,
}: ServiceHeaderProps) => {
return (
<>
<motion.div
className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
<div className="flex items-center gap-2">
<Icon className="w-5 h-5" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{title}
</h2>
</div>
<div className="flex items-center gap-2">
{additionalInfo}
{onTestConnection && (
<Button
onClick={onTestConnection}
disabled={isTestingConnection}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isTestingConnection ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Testing...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Test Connection
</>
)}
</Button>
)}
</div>
</motion.div>
{description && (
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{description}
</p>
)}
</>
);
},
);

View File

@@ -1,6 +0,0 @@
export { ConnectionTestIndicator } from './ConnectionTestIndicator';
export type { ConnectionTestResult } from './ConnectionTestIndicator';
export { ServiceHeader } from './ServiceHeader';
export { ConnectionForm } from './ConnectionForm';
export { LoadingState, Skeleton, ServiceLoading } from './LoadingState';
export { ErrorState, ConnectionError } from './ErrorState';

View File

@@ -1,721 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '~/components/ui/Button';
import { ConfirmationDialog, SelectionDialog } from '~/components/ui/Dialog';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '~/components/ui/Card';
import { motion } from 'framer-motion';
import { useDataOperations } from '~/lib/hooks/useDataOperations';
import { openDatabase } from '~/lib/persistence/db';
import { getAllChats, type Chat } from '~/lib/persistence/chats';
import { DataVisualization } from './DataVisualization';
import { classNames } from '~/utils/classNames';
import { toast } from 'react-toastify';
// Create a custom hook to connect to the boltHistory database
function useBoltHistoryDB() {
const [db, setDb] = useState<IDBDatabase | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const initDB = async () => {
try {
setIsLoading(true);
const database = await openDatabase();
setDb(database || null);
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error initializing database'));
setIsLoading(false);
}
};
initDB();
return () => {
if (db) {
db.close();
}
};
}, []);
return { db, isLoading, error };
}
// Extend the Chat interface to include the missing properties
interface ExtendedChat extends Chat {
title?: string;
updatedAt?: number;
}
// Helper function to create a chat label and description
function createChatItem(chat: Chat): ChatItem {
return {
id: chat.id,
// Use description as title if available, or format a short ID
label: (chat as ExtendedChat).title || chat.description || `Chat ${chat.id.slice(0, 8)}`,
// Format the description with message count and timestamp
description: `${chat.messages.length} messages - Last updated: ${new Date((chat as ExtendedChat).updatedAt || Date.parse(chat.timestamp)).toLocaleString()}`,
};
}
interface SettingsCategory {
id: string;
label: string;
description: string;
}
interface ChatItem {
id: string;
label: string;
description: string;
}
export function DataTab() {
// Use our custom hook for the boltHistory database
const { db, isLoading: dbLoading } = useBoltHistoryDB();
const fileInputRef = useRef<HTMLInputElement>(null);
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
const chatFileInputRef = useRef<HTMLInputElement>(null);
// State for confirmation dialogs
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
const [showSettingsSelection, setShowSettingsSelection] = useState(false);
const [showChatsSelection, setShowChatsSelection] = useState(false);
// State for settings categories and available chats
const [settingsCategories] = useState<SettingsCategory[]>([
{ id: 'core', label: 'Core Settings', description: 'User profile and main settings' },
{ id: 'providers', label: 'Providers', description: 'API keys and provider configurations' },
{ id: 'features', label: 'Features', description: 'Feature flags and settings' },
{ id: 'ui', label: 'UI', description: 'UI configuration and preferences' },
{ id: 'connections', label: 'Connections', description: 'External service connections' },
{ id: 'debug', label: 'Debug', description: 'Debug settings and logs' },
{ id: 'updates', label: 'Updates', description: 'Update settings and notifications' },
]);
const [availableChats, setAvailableChats] = useState<ExtendedChat[]>([]);
const [chatItems, setChatItems] = useState<ChatItem[]>([]);
// Data operations hook with boltHistory database
const {
isExporting,
isImporting,
isResetting,
isDownloadingTemplate,
handleExportSettings,
handleExportSelectedSettings,
handleExportAllChats,
handleExportSelectedChats,
handleImportSettings,
handleImportChats,
handleResetSettings,
handleResetChats,
handleDownloadTemplate,
handleImportAPIKeys,
} = useDataOperations({
customDb: db || undefined, // Pass the boltHistory database, converting null to undefined
onReloadSettings: () => window.location.reload(),
onReloadChats: () => {
// Reload chats after reset
if (db) {
getAllChats(db).then((chats) => {
// Cast to ExtendedChat to handle additional properties
const extendedChats = chats as ExtendedChat[];
setAvailableChats(extendedChats);
setChatItems(extendedChats.map((chat) => createChatItem(chat)));
});
}
},
onResetSettings: () => setShowResetInlineConfirm(false),
onResetChats: () => setShowDeleteInlineConfirm(false),
});
// Loading states for operations not provided by the hook
const [isDeleting, setIsDeleting] = useState(false);
const [isImportingKeys, setIsImportingKeys] = useState(false);
// Load available chats
useEffect(() => {
if (db) {
console.log('Loading chats from boltHistory database', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
getAllChats(db)
.then((chats) => {
console.log('Found chats:', chats.length);
// Cast to ExtendedChat to handle additional properties
const extendedChats = chats as ExtendedChat[];
setAvailableChats(extendedChats);
// Create ChatItems for selection dialog
setChatItems(extendedChats.map((chat) => createChatItem(chat)));
})
.catch((error) => {
console.error('Error loading chats:', error);
toast.error('Failed to load chats: ' + (error instanceof Error ? error.message : 'Unknown error'));
});
}
}, [db]);
// Handle file input changes
const handleFileInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleImportSettings(file);
}
},
[handleImportSettings],
);
const handleAPIKeyFileInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setIsImportingKeys(true);
handleImportAPIKeys(file).finally(() => setIsImportingKeys(false));
}
},
[handleImportAPIKeys],
);
const handleChatFileInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleImportChats(file);
}
},
[handleImportChats],
);
// Wrapper for reset chats to handle loading state
const handleResetChatsWithState = useCallback(() => {
setIsDeleting(true);
handleResetChats().finally(() => setIsDeleting(false));
}, [handleResetChats]);
return (
<div className="space-y-12">
{/* Hidden file inputs */}
<input ref={fileInputRef} type="file" accept=".json" onChange={handleFileInputChange} className="hidden" />
<input
ref={apiKeyFileInputRef}
type="file"
accept=".json"
onChange={handleAPIKeyFileInputChange}
className="hidden"
/>
<input
ref={chatFileInputRef}
type="file"
accept=".json"
onChange={handleChatFileInputChange}
className="hidden"
/>
{/* Reset Settings Confirmation Dialog */}
<ConfirmationDialog
isOpen={showResetInlineConfirm}
onClose={() => setShowResetInlineConfirm(false)}
title="Reset All Settings?"
description="This will reset all your settings to their default values. This action cannot be undone."
confirmLabel="Reset Settings"
cancelLabel="Cancel"
variant="destructive"
isLoading={isResetting}
onConfirm={handleResetSettings}
/>
{/* Delete Chats Confirmation Dialog */}
<ConfirmationDialog
isOpen={showDeleteInlineConfirm}
onClose={() => setShowDeleteInlineConfirm(false)}
title="Delete All Chats?"
description="This will permanently delete all your chat history. This action cannot be undone."
confirmLabel="Delete All"
cancelLabel="Cancel"
variant="destructive"
isLoading={isDeleting}
onConfirm={handleResetChatsWithState}
/>
{/* Settings Selection Dialog */}
<SelectionDialog
isOpen={showSettingsSelection}
onClose={() => setShowSettingsSelection(false)}
title="Select Settings to Export"
items={settingsCategories}
onConfirm={(selectedIds) => {
handleExportSelectedSettings(selectedIds);
setShowSettingsSelection(false);
}}
confirmLabel="Export Selected"
/>
{/* Chats Selection Dialog */}
<SelectionDialog
isOpen={showChatsSelection}
onClose={() => setShowChatsSelection(false)}
title="Select Chats to Export"
items={chatItems}
onConfirm={(selectedIds) => {
handleExportSelectedChats(selectedIds);
setShowChatsSelection(false);
}}
confirmLabel="Export Selected"
/>
{/* Chats Section */}
<div>
<h2 className="text-xl font-semibold mb-4 text-bolt-elements-textPrimary">Chats</h2>
{dbLoading ? (
<div className="flex items-center justify-center p-4">
<div className="i-ph-spinner-gap-bold animate-spin w-6 h-6 mr-2" />
<span>Loading chats database...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-download-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Export All Chats
</CardTitle>
</div>
<CardDescription>Export all your chats to a JSON file.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={async () => {
try {
if (!db) {
toast.error('Database not available');
return;
}
console.log('Database information:', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
if (availableChats.length === 0) {
toast.warning('No chats available to export');
return;
}
await handleExportAllChats();
} catch (error) {
console.error('Error exporting chats:', error);
toast.error(
`Failed to export chats: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}}
disabled={isExporting || availableChats.length === 0}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isExporting || availableChats.length === 0 ? 'cursor-not-allowed' : '',
)}
>
{isExporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Exporting...
</>
) : availableChats.length === 0 ? (
'No Chats to Export'
) : (
'Export All'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph:list-checks w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Export Selected Chats
</CardTitle>
</div>
<CardDescription>Choose specific chats to export.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => setShowChatsSelection(true)}
disabled={isExporting || chatItems.length === 0}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isExporting || chatItems.length === 0 ? 'cursor-not-allowed' : '',
)}
>
{isExporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Exporting...
</>
) : (
'Select Chats'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-upload-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Import Chats
</CardTitle>
</div>
<CardDescription>Import chats from a JSON file.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => chatFileInputRef.current?.click()}
disabled={isImporting}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isImporting ? 'cursor-not-allowed' : '',
)}
>
{isImporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Importing...
</>
) : (
'Import Chats'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div
className="text-red-500 dark:text-red-400 mr-2"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph-trash-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Delete All Chats
</CardTitle>
</div>
<CardDescription>Delete all your chat history.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => setShowDeleteInlineConfirm(true)}
disabled={isDeleting || chatItems.length === 0}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isDeleting || chatItems.length === 0 ? 'cursor-not-allowed' : '',
)}
>
{isDeleting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Deleting...
</>
) : (
'Delete All'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
</div>
)}
</div>
{/* Settings Section */}
<div>
<h2 className="text-xl font-semibold mb-4 text-bolt-elements-textPrimary">Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-download-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Export All Settings
</CardTitle>
</div>
<CardDescription>Export all your settings to a JSON file.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={handleExportSettings}
disabled={isExporting}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isExporting ? 'cursor-not-allowed' : '',
)}
>
{isExporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Exporting...
</>
) : (
'Export All'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-filter-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Export Selected Settings
</CardTitle>
</div>
<CardDescription>Choose specific settings to export.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => setShowSettingsSelection(true)}
disabled={isExporting || settingsCategories.length === 0}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isExporting || settingsCategories.length === 0 ? 'cursor-not-allowed' : '',
)}
>
{isExporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Exporting...
</>
) : (
'Select Settings'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-upload-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Import Settings
</CardTitle>
</div>
<CardDescription>Import settings from a JSON file.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isImporting ? 'cursor-not-allowed' : '',
)}
>
{isImporting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Importing...
</>
) : (
'Import Settings'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div
className="text-red-500 dark:text-red-400 mr-2"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph-arrow-counter-clockwise-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Reset All Settings
</CardTitle>
</div>
<CardDescription>Reset all settings to their default values.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => setShowResetInlineConfirm(true)}
disabled={isResetting}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isResetting ? 'cursor-not-allowed' : '',
)}
>
{isResetting ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Resetting...
</>
) : (
'Reset All'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
</div>
</div>
{/* API Keys Section */}
<div>
<h2 className="text-xl font-semibold mb-4 text-bolt-elements-textPrimary">API Keys</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-file-text-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Download Template
</CardTitle>
</div>
<CardDescription>Download a template file for your API keys.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={handleDownloadTemplate}
disabled={isDownloadingTemplate}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isDownloadingTemplate ? 'cursor-not-allowed' : '',
)}
>
{isDownloadingTemplate ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Downloading...
</>
) : (
'Download'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex items-center mb-2">
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<div className="i-ph-upload-duotone w-5 h-5" />
</motion.div>
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
Import API Keys
</CardTitle>
</div>
<CardDescription>Import API keys from a JSON file.</CardDescription>
</CardHeader>
<CardFooter>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} className="w-full">
<Button
onClick={() => apiKeyFileInputRef.current?.click()}
disabled={isImportingKeys}
variant="outline"
size="sm"
className={classNames(
'hover:text-bolt-elements-item-contentAccent hover:border-bolt-elements-item-backgroundAccent hover:bg-bolt-elements-item-backgroundAccent transition-colors w-full justify-center',
isImportingKeys ? 'cursor-not-allowed' : '',
)}
>
{isImportingKeys ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Importing...
</>
) : (
'Import Keys'
)}
</Button>
</motion.div>
</CardFooter>
</Card>
</div>
</div>
{/* Data Visualization */}
<div>
<h2 className="text-xl font-semibold mb-4 text-bolt-elements-textPrimary">Data Usage</h2>
<Card>
<CardContent className="p-5">
<DataVisualization chats={availableChats} />
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,384 +0,0 @@
import { useState, useEffect } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
PointElement,
LineElement,
} from 'chart.js';
import { Bar, Pie } from 'react-chartjs-2';
import type { Chat } from '~/lib/persistence/chats';
import { classNames } from '~/utils/classNames';
// Register ChartJS components
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
type DataVisualizationProps = {
chats: Chat[];
};
export function DataVisualization({ chats }: DataVisualizationProps) {
const [chatsByDate, setChatsByDate] = useState<Record<string, number>>({});
const [messagesByRole, setMessagesByRole] = useState<Record<string, number>>({});
const [apiKeyUsage, setApiKeyUsage] = useState<Array<{ provider: string; count: number }>>([]);
const [averageMessagesPerChat, setAverageMessagesPerChat] = useState<number>(0);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
const isDark = document.documentElement.classList.contains('dark');
setIsDarkMode(isDark);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'));
}
});
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!chats || chats.length === 0) {
return;
}
// Process chat data
const chatDates: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
const apiUsage: Record<string, number> = {};
let totalMessages = 0;
chats.forEach((chat) => {
const date = new Date(chat.timestamp).toLocaleDateString();
chatDates[date] = (chatDates[date] || 0) + 1;
chat.messages.forEach((message) => {
roleCounts[message.role] = (roleCounts[message.role] || 0) + 1;
totalMessages++;
if (message.role === 'assistant') {
const providerMatch = message.content.match(/provider:\s*([\w-]+)/i);
const provider = providerMatch ? providerMatch[1] : 'unknown';
apiUsage[provider] = (apiUsage[provider] || 0) + 1;
}
});
});
const sortedDates = Object.keys(chatDates).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
const sortedChatsByDate: Record<string, number> = {};
sortedDates.forEach((date) => {
sortedChatsByDate[date] = chatDates[date];
});
setChatsByDate(sortedChatsByDate);
setMessagesByRole(roleCounts);
setApiKeyUsage(Object.entries(apiUsage).map(([provider, count]) => ({ provider, count })));
setAverageMessagesPerChat(totalMessages / chats.length);
}, [chats]);
// Get theme colors from CSS variables to ensure theme consistency
const getThemeColor = (varName: string): string => {
// Get the CSS variable value from document root
if (typeof document !== 'undefined') {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
}
// Fallback for SSR
return isDarkMode ? '#FFFFFF' : '#000000';
};
// Theme-aware chart colors with enhanced dark mode visibility using CSS variables
const chartColors = {
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
text: getThemeColor('--bolt-elements-textPrimary'),
textSecondary: getThemeColor('--bolt-elements-textSecondary'),
background: getThemeColor('--bolt-elements-bg-depth-1'),
accent: getThemeColor('--bolt-elements-button-primary-text'),
border: getThemeColor('--bolt-elements-borderColor'),
};
const getChartColors = (index: number) => {
// Define color palettes based on Bolt design tokens
const baseColors = [
// Indigo
{
base: getThemeColor('--bolt-elements-button-primary-text'),
},
// Pink
{
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
},
// Green
{
base: getThemeColor('--bolt-elements-icon-success'),
},
// Yellow
{
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
},
// Blue
{
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
},
];
// Get the base color for this index
const color = baseColors[index % baseColors.length].base;
// Parse color and generate variations with appropriate opacity
let r = 0,
g = 0,
b = 0;
// Handle rgb/rgba format
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
if (rgbMatch) {
[, r, g, b] = rgbMatch.map(Number);
} else if (rgbaMatch) {
[, r, g, b] = rgbaMatch.map(Number);
} else if (color.startsWith('#')) {
// Handle hex format
const hex = color.slice(1);
const bigint = parseInt(hex, 16);
r = (bigint >> 16) & 255;
g = (bigint >> 8) & 255;
b = bigint & 255;
}
return {
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
};
};
const chartData = {
history: {
labels: Object.keys(chatsByDate),
datasets: [
{
label: 'Chats Created',
data: Object.values(chatsByDate),
backgroundColor: getChartColors(0).bg,
borderColor: getChartColors(0).border,
borderWidth: 1,
},
],
},
roles: {
labels: Object.keys(messagesByRole),
datasets: [
{
label: 'Messages by Role',
data: Object.values(messagesByRole),
backgroundColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).bg),
borderColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).border),
borderWidth: 1,
},
],
},
apiUsage: {
labels: apiKeyUsage.map((item) => item.provider),
datasets: [
{
label: 'API Usage',
data: apiKeyUsage.map((item) => item.count),
backgroundColor: apiKeyUsage.map((_, i) => getChartColors(i).bg),
borderColor: apiKeyUsage.map((_, i) => getChartColors(i).border),
borderWidth: 1,
},
],
},
};
const baseChartOptions = {
responsive: true,
maintainAspectRatio: false,
color: chartColors.text,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.text,
font: {
weight: 'bold' as const,
size: 12,
},
padding: 16,
usePointStyle: true,
},
},
title: {
display: true,
color: chartColors.text,
font: {
size: 16,
weight: 'bold' as const,
},
padding: 16,
},
tooltip: {
titleColor: chartColors.text,
bodyColor: chartColors.text,
backgroundColor: isDarkMode
? 'rgba(23, 23, 23, 0.8)' // Dark bg using Tailwind gray-900
: 'rgba(255, 255, 255, 0.8)', // Light bg
borderColor: chartColors.border,
borderWidth: 1,
},
},
};
const chartOptions = {
...baseChartOptions,
plugins: {
...baseChartOptions.plugins,
title: {
...baseChartOptions.plugins.title,
text: 'Chat History',
},
},
scales: {
x: {
grid: {
color: chartColors.grid,
drawBorder: false,
},
border: {
display: false,
},
ticks: {
color: chartColors.text,
font: {
weight: 500,
},
},
},
y: {
grid: {
color: chartColors.grid,
drawBorder: false,
},
border: {
display: false,
},
ticks: {
color: chartColors.text,
font: {
weight: 500,
},
},
},
},
};
const pieOptions = {
...baseChartOptions,
plugins: {
...baseChartOptions.plugins,
title: {
...baseChartOptions.plugins.title,
text: 'Message Distribution',
},
legend: {
...baseChartOptions.plugins.legend,
position: 'right' as const,
},
datalabels: {
color: chartColors.text,
font: {
weight: 'bold' as const,
},
},
},
};
if (chats.length === 0) {
return (
<div className="text-center py-8">
<div className="i-ph-chart-line-duotone w-12 h-12 mx-auto mb-4 text-bolt-elements-textTertiary opacity-80" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Data Available</h3>
<p className="text-bolt-elements-textSecondary">
Start creating chats to see your usage statistics and data visualization.
</p>
</div>
);
}
const cardClasses = classNames(
'p-6 rounded-lg shadow-sm',
'bg-bolt-elements-bg-depth-1',
'border border-bolt-elements-borderColor',
);
const statClasses = classNames('text-3xl font-bold text-bolt-elements-textPrimary', 'flex items-center gap-3');
return (
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Chats</h3>
<div className={statClasses}>
<div className="i-ph-chats-duotone w-8 h-8 text-indigo-500 dark:text-indigo-400" />
<span>{chats.length}</span>
</div>
</div>
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Messages</h3>
<div className={statClasses}>
<div className="i-ph-chat-text-duotone w-8 h-8 text-pink-500 dark:text-pink-400" />
<span>{Object.values(messagesByRole).reduce((sum, count) => sum + count, 0)}</span>
</div>
</div>
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Avg. Messages/Chat</h3>
<div className={statClasses}>
<div className="i-ph-chart-bar-duotone w-8 h-8 text-green-500 dark:text-green-400" />
<span>{averageMessagesPerChat.toFixed(1)}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Chat History</h3>
<div className="h-64">
<Bar data={chartData.history} options={chartOptions} />
</div>
</div>
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Message Distribution</h3>
<div className="h-64">
<Pie data={chartData.roles} options={pieOptions} />
</div>
</div>
</div>
{apiKeyUsage.length > 0 && (
<div className={cardClasses}>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">API Usage by Provider</h3>
<div className="h-64">
<Pie data={chartData.apiUsage} options={pieOptions} />
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,281 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
import { LoadingState, ErrorState, ConnectionTestIndicator, RepositoryCard } from './components/shared';
import { GitHubConnection } from './components/GitHubConnection';
import { GitHubUserProfile } from './components/GitHubUserProfile';
import { GitHubStats } from './components/GitHubStats';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { classNames } from '~/utils/classNames';
import { ChevronDown } from 'lucide-react';
import { GitHubErrorBoundary } from './components/GitHubErrorBoundary';
import { GitHubProgressiveLoader } from './components/GitHubProgressiveLoader';
import { GitHubCacheManager } from './components/GitHubCacheManager';
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
// GitHub logo SVG component
const GithubLogo = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
);
export default function GitHubTab() {
const { connection, isConnected, isLoading, error, testConnection } = useGitHubConnection();
const {
stats,
isLoading: isStatsLoading,
error: statsError,
} = useGitHubStats(
connection,
{
autoFetch: true,
cacheTimeout: 30 * 60 * 1000, // 30 minutes
},
isConnected && connection ? !connection.token : false,
); // Use server-side when no token but connected
const [connectionTest, setConnectionTest] = useState<ConnectionTestResult | null>(null);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const [isReposExpanded, setIsReposExpanded] = useState(false);
const handleTestConnection = async () => {
if (!connection?.user) {
setConnectionTest({
status: 'error',
message: 'No connection established',
timestamp: Date.now(),
});
return;
}
setConnectionTest({
status: 'testing',
message: 'Testing connection...',
});
try {
const isValid = await testConnection();
if (isValid) {
setConnectionTest({
status: 'success',
message: `Connected successfully as ${connection.user.login}`,
timestamp: Date.now(),
});
} else {
setConnectionTest({
status: 'error',
message: 'Connection test failed',
timestamp: Date.now(),
});
}
} catch (error) {
setConnectionTest({
status: 'error',
message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: Date.now(),
});
}
};
// Loading state for initial connection check
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GithubLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
</div>
<LoadingState message="Checking GitHub connection..." />
</div>
);
}
// Error state for connection issues
if (error && !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GithubLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
</div>
<ErrorState
title="Connection Error"
message={error}
onRetry={() => window.location.reload()}
retryLabel="Reload Page"
/>
</div>
);
}
// Not connected state
if (!isConnected || !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GithubLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
</div>
<p className="text-sm text-bolt-elements-textSecondary">
Connect your GitHub account to enable advanced repository management features, statistics, and seamless
integration.
</p>
<GitHubConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
</div>
);
}
return (
<GitHubErrorBoundary>
<div className="space-y-6">
{/* Header */}
<motion.div
className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
<GithubLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub Integration
</h2>
</div>
<div className="flex items-center gap-2">
{connection?.rateLimit && (
<div className="flex items-center gap-2 px-3 py-1 bg-bolt-elements-background-depth-1 rounded-lg text-xs">
<div className="i-ph:cloud w-4 h-4 text-bolt-elements-textSecondary" />
<span className="text-bolt-elements-textSecondary">
API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
</span>
</div>
)}
</div>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your GitHub integration with advanced repository features and comprehensive statistics
</p>
{/* Connection Test Results */}
<ConnectionTestIndicator
status={connectionTest?.status || null}
message={connectionTest?.message}
timestamp={connectionTest?.timestamp}
/>
{/* Connection Component */}
<GitHubConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
{/* User Profile */}
{connection.user && <GitHubUserProfile user={connection.user} />}
{/* Stats Section */}
<GitHubStats connection={connection} isExpanded={isStatsExpanded} onToggleExpanded={setIsStatsExpanded} />
{/* Repositories Section */}
{stats?.repos && stats.repos.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<Collapsible open={isReposExpanded} onOpenChange={setIsReposExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:folder w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
All Repositories ({stats.repos.length})
</span>
</div>
<ChevronDown
className={classNames(
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isReposExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(isReposExpanded ? stats.repos : stats.repos.slice(0, 12)).map((repo) => (
<RepositoryCard
key={repo.full_name}
repository={repo}
variant="detailed"
showHealthScore
showExtendedMetrics
onSelect={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
/>
))}
</div>
{stats.repos.length > 12 && !isReposExpanded && (
<div className="text-center">
<Button
variant="outline"
onClick={() => setIsReposExpanded(true)}
className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
Show {stats.repos.length - 12} more repositories
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</motion.div>
)}
{/* Stats Error State */}
{statsError && !stats && (
<ErrorState
title="Failed to Load Statistics"
message={statsError}
onRetry={() => window.location.reload()}
retryLabel="Retry"
/>
)}
{/* Stats Loading State */}
{isStatsLoading && !stats && (
<GitHubProgressiveLoader
isLoading={isStatsLoading}
loadingMessage="Loading GitHub statistics..."
showProgress={true}
progressSteps={[
{ key: 'user', label: 'Fetching user info', completed: !!connection?.user, loading: !connection?.user },
{ key: 'repos', label: 'Loading repositories', completed: false, loading: true },
{ key: 'stats', label: 'Calculating statistics', completed: false },
{ key: 'cache', label: 'Updating cache', completed: false },
]}
>
<div />
</GitHubProgressiveLoader>
)}
{/* Cache Management Section - Only show when connected */}
{isConnected && connection && (
<div className="mt-8 pt-6 border-t border-bolt-elements-borderColor">
<GitHubCacheManager showStats={true} />
</div>
)}
</div>
</GitHubErrorBoundary>
);
}

View File

@@ -1,173 +0,0 @@
import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { useGitHubConnection } from '~/lib/hooks';
interface GitHubAuthDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export function GitHubAuthDialog({ isOpen, onClose, onSuccess }: GitHubAuthDialogProps) {
const { connect, isConnecting, error } = useGitHubConnection();
const [token, setToken] = useState('');
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
return;
}
try {
await connect(token, tokenType);
setToken(''); // Clear token on successful connection
onSuccess?.();
onClose();
} catch {
// Error handling is done in the hook
}
};
const handleClose = () => {
setToken('');
onClose();
};
return (
<Dialog.Root open={isOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-[200]" />
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[201] w-full max-w-md"
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
>
<motion.div
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg shadow-lg"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Connect to GitHub</h2>
<button
onClick={handleClose}
className="p-1 rounded-md hover:bg-bolt-elements-item-backgroundActive/10"
>
<div className="i-ph:x w-4 h-4 text-bolt-elements-textSecondary" />
</button>
</div>
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You need a GitHub token to deploy repositories.
</p>
<p>Required scopes: repo, read:org, read:user</p>
</div>
<form onSubmit={handleConnect} className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
<select
value={tokenType}
onChange={(e) => setTokenType(e.target.value as 'classic' | 'fine-grained')}
disabled={isConnecting}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent',
'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">
{tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting}
placeholder={`Enter your GitHub ${
tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
}`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`https://github.com/settings/tokens${tokenType === 'fine-grained' ? '/beta' : '/new'}`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-4">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
Cancel
</button>
<button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
</form>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,367 +0,0 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
import { Database, Trash2, RefreshCw, Clock, HardDrive, CheckCircle } from 'lucide-react';
interface CacheEntry {
key: string;
size: number;
timestamp: number;
lastAccessed: number;
data: any;
}
interface CacheStats {
totalSize: number;
totalEntries: number;
oldestEntry: number;
newestEntry: number;
hitRate?: number;
}
interface GitHubCacheManagerProps {
className?: string;
showStats?: boolean;
}
// Cache management utilities
class CacheManagerService {
private static readonly _cachePrefix = 'github_';
private static readonly _cacheKeys = [
'github_connection',
'github_stats_cache',
'github_repositories_cache',
'github_user_cache',
'github_rate_limits',
];
static getCacheEntries(): CacheEntry[] {
const entries: CacheEntry[] = [];
for (const key of this._cacheKeys) {
try {
const data = localStorage.getItem(key);
if (data) {
const parsed = JSON.parse(data);
entries.push({
key,
size: new Blob([data]).size,
timestamp: parsed.timestamp || Date.now(),
lastAccessed: parsed.lastAccessed || Date.now(),
data: parsed,
});
}
} catch (error) {
console.warn(`Failed to parse cache entry: ${key}`, error);
}
}
return entries.sort((a, b) => b.lastAccessed - a.lastAccessed);
}
static getCacheStats(): CacheStats {
const entries = this.getCacheEntries();
if (entries.length === 0) {
return {
totalSize: 0,
totalEntries: 0,
oldestEntry: 0,
newestEntry: 0,
};
}
const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
const timestamps = entries.map((e) => e.timestamp);
return {
totalSize,
totalEntries: entries.length,
oldestEntry: Math.min(...timestamps),
newestEntry: Math.max(...timestamps),
};
}
static clearCache(keys?: string[]): void {
const keysToRemove = keys || this._cacheKeys;
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
}
static clearExpiredCache(maxAge: number = 24 * 60 * 60 * 1000): number {
const entries = this.getCacheEntries();
const now = Date.now();
let removedCount = 0;
for (const entry of entries) {
if (now - entry.timestamp > maxAge) {
localStorage.removeItem(entry.key);
removedCount++;
}
}
return removedCount;
}
static compactCache(): void {
const entries = this.getCacheEntries();
for (const entry of entries) {
try {
// Re-serialize with minimal data
const compacted = {
...entry.data,
lastAccessed: Date.now(),
};
localStorage.setItem(entry.key, JSON.stringify(compacted));
} catch (error) {
console.warn(`Failed to compact cache entry: ${entry.key}`, error);
}
}
}
static formatSize(bytes: number): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
}
export function GitHubCacheManager({ className = '', showStats = true }: GitHubCacheManagerProps) {
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [lastClearTime, setLastClearTime] = useState<number | null>(null);
const refreshCacheData = useCallback(() => {
setCacheEntries(CacheManagerService.getCacheEntries());
}, []);
useEffect(() => {
refreshCacheData();
}, [refreshCacheData]);
const cacheStats = useMemo(() => CacheManagerService.getCacheStats(), [cacheEntries]);
const handleClearAll = useCallback(async () => {
setIsLoading(true);
try {
CacheManagerService.clearCache();
setLastClearTime(Date.now());
refreshCacheData();
// Trigger a page refresh to update all components
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
console.error('Failed to clear cache:', error);
} finally {
setIsLoading(false);
}
}, [refreshCacheData]);
const handleClearExpired = useCallback(() => {
setIsLoading(true);
try {
const removedCount = CacheManagerService.clearExpiredCache();
refreshCacheData();
if (removedCount > 0) {
// Show success message or trigger update
console.log(`Removed ${removedCount} expired cache entries`);
}
} catch (error) {
console.error('Failed to clear expired cache:', error);
} finally {
setIsLoading(false);
}
}, [refreshCacheData]);
const handleCompactCache = useCallback(() => {
setIsLoading(true);
try {
CacheManagerService.compactCache();
refreshCacheData();
} catch (error) {
console.error('Failed to compact cache:', error);
} finally {
setIsLoading(false);
}
}, [refreshCacheData]);
const handleClearSpecific = useCallback(
(key: string) => {
setIsLoading(true);
try {
CacheManagerService.clearCache([key]);
refreshCacheData();
} catch (error) {
console.error(`Failed to clear cache key: ${key}`, error);
} finally {
setIsLoading(false);
}
},
[refreshCacheData],
);
if (!showStats && cacheEntries.length === 0) {
return null;
}
return (
<div
className={classNames(
'space-y-4 p-4 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg',
className,
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-bolt-elements-item-contentAccent" />
<h3 className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Cache Management</h3>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={refreshCacheData} disabled={isLoading}>
<RefreshCw className={classNames('w-3 h-3', isLoading ? 'animate-spin' : '')} />
</Button>
</div>
</div>
{showStats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="w-3 h-3 text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textSecondary">Total Size</span>
</div>
<p className="text-sm font-semibold text-bolt-elements-textPrimary">
{CacheManagerService.formatSize(cacheStats.totalSize)}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Database className="w-3 h-3 text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textSecondary">Entries</span>
</div>
<p className="text-sm font-semibold text-bolt-elements-textPrimary">{cacheStats.totalEntries}</p>
</div>
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-3 h-3 text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textSecondary">Oldest</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">
{cacheStats.oldestEntry ? new Date(cacheStats.oldestEntry).toLocaleDateString() : 'N/A'}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-3 h-3 text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textSecondary">Status</span>
</div>
<p className="text-xs text-green-600 dark:text-green-400">
{cacheStats.totalEntries > 0 ? 'Active' : 'Empty'}
</p>
</div>
</div>
)}
{cacheEntries.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-bolt-elements-textSecondary">
Cache Entries ({cacheEntries.length})
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{cacheEntries.map((entry) => (
<div
key={entry.key}
className="flex items-center justify-between p-2 bg-bolt-elements-background-depth-2 rounded border border-bolt-elements-borderColor"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-bolt-elements-textPrimary truncate">
{entry.key.replace('github_', '')}
</p>
<p className="text-xs text-bolt-elements-textSecondary">
{CacheManagerService.formatSize(entry.size)} {new Date(entry.lastAccessed).toLocaleString()}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleClearSpecific(entry.key)}
disabled={isLoading}
className="ml-2"
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2 border-t border-bolt-elements-borderColor">
<Button
variant="outline"
size="sm"
onClick={handleClearExpired}
disabled={isLoading}
className="flex items-center gap-1"
>
<Clock className="w-3 h-3" />
<span className="text-xs">Clear Expired</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCompactCache}
disabled={isLoading}
className="flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
<span className="text-xs">Compact</span>
</Button>
{cacheEntries.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
disabled={isLoading}
className="flex items-center gap-1 text-red-600 hover:text-red-700 border-red-200 hover:border-red-300"
>
<Trash2 className="w-3 h-3" />
<span className="text-xs">Clear All</span>
</Button>
)}
</div>
{lastClearTime && (
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded text-xs text-green-700 dark:text-green-400">
<CheckCircle className="w-3 h-3" />
<span>Cache cleared successfully at {new Date(lastClearTime).toLocaleTimeString()}</span>
</div>
)}
</div>
);
}

View File

@@ -1,233 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
import { useGitHubConnection } from '~/lib/hooks';
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
interface GitHubConnectionProps {
connectionTest: ConnectionTestResult | null;
onTestConnection: () => void;
}
export function GitHubConnection({ connectionTest, onTestConnection }: GitHubConnectionProps) {
const { isConnected, isLoading, isConnecting, connect, disconnect, error } = useGitHubConnection();
const [token, setToken] = React.useState('');
const [tokenType, setTokenType] = React.useState<'classic' | 'fine-grained'>('classic');
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
console.log('handleConnect called with token:', token ? 'token provided' : 'no token', 'tokenType:', tokenType);
if (!token.trim()) {
console.log('No token provided, returning early');
return;
}
try {
console.log('Calling connect function...');
await connect(token, tokenType);
console.log('Connect function completed successfully');
setToken(''); // Clear token on successful connection
} catch (error) {
console.log('Connect function failed:', error);
// Error handling is done in the hook
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<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 connection...</span>
</div>
</div>
);
}
return (
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
{!isConnected && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
VITE_GITHUB_ACCESS_TOKEN
</code>{' '}
environment variable to connect automatically.
</p>
<p>
For fine-grained tokens, also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
VITE_GITHUB_TOKEN_TYPE=fine-grained
</code>
</p>
</div>
)}
<form onSubmit={handleConnect} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
Token Type
</label>
<select
value={tokenType}
onChange={(e) => setTokenType(e.target.value as 'classic' | 'fine-grained')}
disabled={isConnecting || isConnected}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
'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 dark:text-bolt-elements-textSecondary mb-2">
{tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting || isConnected}
placeholder={`Enter your GitHub ${
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-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`https://github.com/settings/tokens${tokenType === 'fine-grained' ? '/beta' : '/new'}`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<span className="mx-2"></span>
<span>
Required scopes:{' '}
{tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'}
</span>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex items-center justify-between">
{!isConnected ? (
<button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
) : (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<button
onClick={disconnect}
type="button"
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 GitHub
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout w-4 h-4" />
Dashboard
</Button>
<Button
onClick={onTestConnection}
disabled={connectionTest?.status === 'testing'}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{connectionTest?.status === 'testing' ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Testing...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Test Connection
</>
)}
</Button>
</div>
</div>
)}
</div>
</form>
</div>
</motion.div>
);
}

View File

@@ -1,105 +0,0 @@
import React, { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
import { Button } from '~/components/ui/Button';
import { AlertTriangle } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class GitHubErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('GitHub Error Boundary caught an error:', error, errorInfo);
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg">
<div className="w-12 h-12 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-500" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">GitHub Integration Error</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4 max-w-md">
Something went wrong while loading GitHub data. This could be due to network issues, API limits, or a
temporary problem.
</p>
{this.state.error && (
<details className="text-xs text-bolt-elements-textTertiary mb-4">
<summary className="cursor-pointer hover:text-bolt-elements-textSecondary">Show error details</summary>
<pre className="mt-2 p-2 bg-bolt-elements-background-depth-2 rounded text-left overflow-auto">
{this.state.error.message}
</pre>
</details>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={this.handleRetry}>
Try Again
</Button>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload Page
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
// Higher-order component for wrapping components with error boundary
export function withGitHubErrorBoundary<P extends object>(component: React.ComponentType<P>) {
return function WrappedComponent(props: P) {
return <GitHubErrorBoundary>{React.createElement(component, props)}</GitHubErrorBoundary>;
};
}
// Hook for handling async errors in GitHub operations
export function useGitHubErrorHandler() {
const handleError = React.useCallback((error: unknown, context?: string) => {
console.error(`GitHub Error ${context ? `(${context})` : ''}:`, error);
/*
* You could integrate with error tracking services here
* For example: Sentry, LogRocket, etc.
*/
return error instanceof Error ? error.message : 'An unknown error occurred';
}, []);
return { handleError };
}

View File

@@ -1,266 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
import { Loader2, ChevronDown, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
interface ProgressiveLoaderProps {
isLoading: boolean;
isRefreshing?: boolean;
error?: string | null;
onRetry?: () => void;
onRefresh?: () => void;
children: React.ReactNode;
className?: string;
loadingMessage?: string;
refreshingMessage?: string;
showProgress?: boolean;
progressSteps?: Array<{
key: string;
label: string;
completed: boolean;
loading?: boolean;
error?: boolean;
}>;
}
export function GitHubProgressiveLoader({
isLoading,
isRefreshing = false,
error,
onRetry,
onRefresh,
children,
className = '',
loadingMessage = 'Loading...',
refreshingMessage = 'Refreshing...',
showProgress = false,
progressSteps = [],
}: ProgressiveLoaderProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Calculate progress percentage
const progress = useMemo(() => {
if (!showProgress || progressSteps.length === 0) {
return 0;
}
const completed = progressSteps.filter((step) => step.completed).length;
return Math.round((completed / progressSteps.length) * 100);
}, [showProgress, progressSteps]);
const handleToggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Loading state with progressive steps
if (isLoading) {
return (
<div className={classNames('flex flex-col items-center justify-center py-8', className)}>
<div className="relative mb-4">
<Loader2 className="w-8 h-8 animate-spin text-bolt-elements-item-contentAccent" />
{showProgress && progress > 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-medium text-bolt-elements-item-contentAccent">{progress}%</span>
</div>
)}
</div>
<div className="text-center space-y-2">
<p className="text-sm font-medium text-bolt-elements-textPrimary">{loadingMessage}</p>
{showProgress && progressSteps.length > 0 && (
<div className="w-full max-w-sm">
{/* Progress bar */}
<div className="w-full bg-bolt-elements-background-depth-2 rounded-full h-2 mb-3">
<motion.div
className="bg-bolt-elements-item-contentAccent h-2 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
{/* Steps toggle */}
<button
onClick={handleToggleExpanded}
className="flex items-center justify-center gap-2 text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
>
<span>Show details</span>
<ChevronDown
className={classNames(
'w-3 h-3 transform transition-transform duration-200',
isExpanded ? 'rotate-180' : '',
)}
/>
</button>
{/* Progress steps */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-3 space-y-2 overflow-hidden"
>
{progressSteps.map((step) => (
<div key={step.key} className="flex items-center gap-2 text-xs">
{step.error ? (
<AlertCircle className="w-3 h-3 text-red-500 flex-shrink-0" />
) : step.completed ? (
<CheckCircle className="w-3 h-3 text-green-500 flex-shrink-0" />
) : step.loading ? (
<Loader2 className="w-3 h-3 animate-spin text-bolt-elements-item-contentAccent flex-shrink-0" />
) : (
<div className="w-3 h-3 rounded-full border border-bolt-elements-borderColor flex-shrink-0" />
)}
<span
className={classNames(
step.error
? 'text-red-500'
: step.completed
? 'text-green-600 dark:text-green-400'
: step.loading
? 'text-bolt-elements-textPrimary'
: 'text-bolt-elements-textSecondary',
)}
>
{step.label}
</span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className={classNames('flex flex-col items-center justify-center py-8 text-center space-y-4', className)}>
<div className="w-10 h-10 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-1">Failed to Load</h3>
<p className="text-xs text-bolt-elements-textSecondary mb-4 max-w-sm">{error}</p>
</div>
<div className="flex gap-2">
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
Try Again
</Button>
)}
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh} className="text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
)}
</div>
</div>
);
}
// Success state - render children with optional refresh indicator
return (
<div className={classNames('relative', className)}>
{isRefreshing && (
<div className="absolute top-0 right-0 z-10">
<div className="flex items-center gap-2 px-2 py-1 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg shadow-sm">
<Loader2 className="w-3 h-3 animate-spin text-bolt-elements-item-contentAccent" />
<span className="text-xs text-bolt-elements-textSecondary">{refreshingMessage}</span>
</div>
</div>
)}
{children}
</div>
);
}
// Hook for managing progressive loading steps
export function useProgressiveLoader() {
const [steps, setSteps] = useState<
Array<{
key: string;
label: string;
completed: boolean;
loading?: boolean;
error?: boolean;
}>
>([]);
const addStep = useCallback((key: string, label: string) => {
setSteps((prev) => [
...prev.filter((step) => step.key !== key),
{ key, label, completed: false, loading: false, error: false },
]);
}, []);
const updateStep = useCallback(
(
key: string,
updates: {
completed?: boolean;
loading?: boolean;
error?: boolean;
label?: string;
},
) => {
setSteps((prev) => prev.map((step) => (step.key === key ? { ...step, ...updates } : step)));
},
[],
);
const removeStep = useCallback((key: string) => {
setSteps((prev) => prev.filter((step) => step.key !== key));
}, []);
const clearSteps = useCallback(() => {
setSteps([]);
}, []);
const startStep = useCallback(
(key: string) => {
updateStep(key, { loading: true, error: false });
},
[updateStep],
);
const completeStep = useCallback(
(key: string) => {
updateStep(key, { completed: true, loading: false, error: false });
},
[updateStep],
);
const errorStep = useCallback(
(key: string) => {
updateStep(key, { error: true, loading: false });
},
[updateStep],
);
return {
steps,
addStep,
updateStep,
removeStep,
clearSteps,
startStep,
completeStep,
errorStep,
};
}

View File

@@ -1,121 +0,0 @@
import React from 'react';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface GitHubRepositoryCardProps {
repo: GitHubRepoInfo;
onClone?: (repo: GitHubRepoInfo) => void;
}
export function GitHubRepositoryCard({ repo, onClone }: GitHubRepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="flex flex-col h-full">
<div className="flex-1 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info" />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
{repo.private && (
<div className="i-ph:lock w-3 h-3 text-bolt-elements-textTertiary" title="Private repository" />
)}
{repo.fork && (
<div className="i-ph:git-fork w-3 h-3 text-bolt-elements-textTertiary" title="Forked repository" />
)}
{repo.archived && (
<div className="i-ph:archive w-3 h-3 text-bolt-elements-textTertiary" title="Archived repository" />
)}
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
{repo.language && (
<span className="flex items-center gap-1" title="Primary Language">
<div className="w-2 h-2 rounded-full bg-current opacity-60" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
{/* Repository topics/tags */}
{repo.topics && repo.topics.length > 0 && (
<div className="flex items-center gap-2 text-xs">
{repo.topics.slice(0, 3).map((topic) => (
<span
key={topic}
className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
title={`Topic: ${topic}`}
>
{topic}
</span>
))}
{repo.topics.length > 3 && (
<span className="text-bolt-elements-textTertiary">+{repo.topics.length - 3} more</span>
)}
</div>
)}
{/* Repository size if available */}
{repo.size && (
<div className="text-xs text-bolt-elements-textTertiary">Size: {(repo.size / 1024).toFixed(1)} MB</div>
)}
</div>
{/* Bottom section with Clone button positioned at bottom right */}
<div className="flex items-center justify-between pt-3 mt-auto">
<span className="flex items-center gap-1 text-xs text-bolt-elements-textSecondary group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
{onClone && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClone(repo);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
title="Clone repository"
>
<div className="i-ph:git-branch w-3.5 h-3.5" />
Clone
</button>
)}
</div>
</div>
</a>
);
}

View File

@@ -1,312 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { BranchSelector } from '~/components/ui/BranchSelector';
import { GitHubRepositoryCard } from './GitHubRepositoryCard';
import type { GitHubRepoInfo } from '~/types/GitHub';
import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
import { classNames } from '~/utils/classNames';
import { Search, RefreshCw, GitBranch, Calendar, Filter } from 'lucide-react';
interface GitHubRepositorySelectorProps {
onClone?: (repoUrl: string, branch?: string) => void;
className?: string;
}
type SortOption = 'updated' | 'stars' | 'name' | 'created';
type FilterOption = 'all' | 'own' | 'forks' | 'archived';
export function GitHubRepositorySelector({ onClone, className }: GitHubRepositorySelectorProps) {
const { connection, isConnected } = useGitHubConnection();
const {
stats,
isLoading: isStatsLoading,
refreshStats,
} = useGitHubStats(connection, {
autoFetch: true,
cacheTimeout: 30 * 60 * 1000, // 30 minutes
});
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('updated');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [currentPage, setCurrentPage] = useState(1);
const [selectedRepo, setSelectedRepo] = useState<GitHubRepoInfo | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const repositories = stats?.repos || [];
const REPOS_PER_PAGE = 12;
// Filter and search repositories
const filteredRepositories = useMemo(() => {
if (!repositories) {
return [];
}
const filtered = repositories.filter((repo: GitHubRepoInfo) => {
// Search filter
const matchesSearch =
!searchQuery ||
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.full_name.toLowerCase().includes(searchQuery.toLowerCase());
// Type filter
let matchesFilter = true;
switch (filterBy) {
case 'own':
matchesFilter = !repo.fork;
break;
case 'forks':
matchesFilter = repo.fork === true;
break;
case 'archived':
matchesFilter = repo.archived === true;
break;
case 'all':
default:
matchesFilter = true;
break;
}
return matchesSearch && matchesFilter;
});
// Sort repositories
filtered.sort((a: GitHubRepoInfo, b: GitHubRepoInfo) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'stars':
return b.stargazers_count - a.stargazers_count;
case 'created':
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); // Using updated_at as proxy
case 'updated':
default:
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
}
});
return filtered;
}, [repositories, searchQuery, sortBy, filterBy]);
// Pagination
const totalPages = Math.ceil(filteredRepositories.length / REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, startIndex + REPOS_PER_PAGE);
const handleRefresh = async () => {
setIsRefreshing(true);
setError(null);
try {
await refreshStats();
} catch (err) {
console.error('Failed to refresh GitHub repositories:', err);
setError(err instanceof Error ? err.message : 'Failed to refresh repositories');
} finally {
setIsRefreshing(false);
}
};
const handleCloneRepository = (repo: GitHubRepoInfo) => {
setSelectedRepo(repo);
setIsBranchSelectorOpen(true);
};
const handleBranchSelect = (branch: string) => {
if (onClone && selectedRepo) {
const cloneUrl = selectedRepo.html_url + '.git';
onClone(cloneUrl, branch);
}
setSelectedRepo(null);
};
const handleCloseBranchSelector = () => {
setIsBranchSelectorOpen(false);
setSelectedRepo(null);
};
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, sortBy, filterBy]);
if (!isConnected || !connection) {
return (
<div className="text-center p-8">
<p className="text-bolt-elements-textSecondary mb-4">Please connect to GitHub first to browse repositories</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh Connection
</Button>
</div>
);
}
if (isStatsLoading && !stats) {
return (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="animate-spin w-8 h-8 border-2 border-bolt-elements-borderColorActive border-t-transparent rounded-full" />
<p className="text-sm text-bolt-elements-textSecondary">Loading repositories...</p>
</div>
);
}
if (!repositories.length) {
return (
<div className="text-center p-8">
<GitBranch className="w-12 h-12 text-bolt-elements-textTertiary mx-auto mb-4" />
<p className="text-bolt-elements-textSecondary mb-4">No repositories found</p>
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
);
}
return (
<motion.div
className={classNames('space-y-6', className)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header with stats */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Select Repository to Clone</h3>
<p className="text-sm text-bolt-elements-textSecondary">
{filteredRepositories.length} of {repositories.length} repositories
</p>
</div>
<Button
onClick={handleRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<RefreshCw className={classNames('w-4 h-4', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
{error && repositories.length > 0 && (
<div className="p-3 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-700">
<p className="text-sm text-yellow-800 dark:text-yellow-200">Warning: {error}. Showing cached data.</p>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bolt-elements-textTertiary" />
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
</div>
{/* Sort */}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="updated">Recently updated</option>
<option value="stars">Most starred</option>
<option value="name">Name (A-Z)</option>
<option value="created">Recently created</option>
</select>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="all">All repositories</option>
<option value="own">Own repositories</option>
<option value="forks">Forked repositories</option>
<option value="archived">Archived repositories</option>
</select>
</div>
</div>
{/* Repository Grid */}
{currentRepositories.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentRepositories.map((repo) => (
<GitHubRepositoryCard key={repo.id} repo={repo} onClone={() => handleCloneRepository(repo)} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8">
<p className="text-bolt-elements-textSecondary">No repositories found matching your search criteria.</p>
</div>
)}
{/* Branch Selector Modal */}
{selectedRepo && (
<BranchSelector
provider="github"
repoOwner={selectedRepo.full_name.split('/')[0]}
repoName={selectedRepo.full_name.split('/')[1]}
token={connection?.token || ''}
defaultBranch={selectedRepo.default_branch}
onBranchSelect={handleBranchSelect}
onClose={handleCloseBranchSelector}
isOpen={isBranchSelectorOpen}
/>
)}
</motion.div>
);
}

View File

@@ -1,291 +0,0 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { classNames } from '~/utils/classNames';
import { useGitHubStats } from '~/lib/hooks';
import type { GitHubConnection, GitHubStats as GitHubStatsType } from '~/types/GitHub';
import { GitHubErrorBoundary } from './GitHubErrorBoundary';
interface GitHubStatsProps {
connection: GitHubConnection;
isExpanded: boolean;
onToggleExpanded: (expanded: boolean) => void;
}
export function GitHubStats({ connection, isExpanded, onToggleExpanded }: GitHubStatsProps) {
const { stats, isLoading, isRefreshing, refreshStats, isStale } = useGitHubStats(
connection,
{
autoFetch: true,
cacheTimeout: 30 * 60 * 1000, // 30 minutes
},
!connection?.token,
); // Use server-side if no token
return (
<GitHubErrorBoundary>
<GitHubStatsContent
stats={stats}
isLoading={isLoading}
isRefreshing={isRefreshing}
refreshStats={refreshStats}
isStale={isStale}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
/>
</GitHubErrorBoundary>
);
}
function GitHubStatsContent({
stats,
isLoading,
isRefreshing,
refreshStats,
isStale,
isExpanded,
onToggleExpanded,
}: {
stats: GitHubStatsType | null;
isLoading: boolean;
isRefreshing: boolean;
refreshStats: () => Promise<void>;
isStale: boolean;
isExpanded: boolean;
onToggleExpanded: (expanded: boolean) => void;
}) {
if (!stats) {
return (
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
<div className="flex items-center justify-center p-8">
<div className="flex items-center gap-2">
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-bolt-elements-textSecondary">Loading GitHub stats...</span>
</>
) : (
<span className="text-bolt-elements-textSecondary">No stats available</span>
)}
</div>
</div>
</div>
);
}
return (
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
GitHub Stats
{isStale && <span className="text-bolt-elements-textTertiary ml-1">(Stale)</span>}
</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={(e) => {
e.stopPropagation();
refreshStats();
}}
disabled={isRefreshing}
variant="outline"
size="sm"
className="text-xs"
>
{isRefreshing ? (
<>
<div className="i-ph:spinner-gap w-3 h-3 animate-spin" />
Refreshing...
</>
) : (
<>
<div className="i-ph:arrows-clockwise w-3 h-3" />
Refresh
</>
)}
</Button>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isExpanded ? 'rotate-180' : '',
)}
/>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
{/* Languages Section */}
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
{stats.mostUsedLanguages && stats.mostUsedLanguages.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{stats.mostUsedLanguages.slice(0, 15).map(({ language, bytes, repos }) => (
<span
key={language}
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
title={`${language}: ${(bytes / 1024 / 1024).toFixed(2)}MB across ${repos} repos`}
>
{language} ({repos})
</span>
))}
</div>
<div className="text-xs text-bolt-elements-textSecondary">
Based on actual codebase size across repositories
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{Object.entries(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-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
>
{language}
</span>
))}
</div>
)}
</div>
{/* GitHub Overview Summary */}
<div className="mb-6 p-4 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">GitHub Overview</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{(stats.publicRepos || 0) + (stats.privateRepos || 0)}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Total Repositories</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">{stats.totalBranches || 0}</div>
<div className="text-xs text-bolt-elements-textSecondary">Total Branches</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{stats.organizations?.length || 0}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Organizations</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{Object.keys(stats.languages).length}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Languages Used</div>
</div>
</div>
</div>
{/* Activity Summary */}
<div className="mb-6">
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Activity Summary</h5>
<div className="grid grid-cols-4 gap-4">
{[
{
label: 'Total Branches',
value: stats.totalBranches || 0,
icon: 'i-ph:git-branch',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Contributors',
value: stats.totalContributors || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
{
label: 'Issues',
value: stats.totalIssues || 0,
icon: 'i-ph:circle',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Pull Requests',
value: stats.totalPullRequests || 0,
icon: 'i-ph:git-pull-request',
iconColor: 'text-bolt-elements-icon-accent',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{/* Organizations Section */}
{stats.organizations && stats.organizations.length > 0 && (
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Organizations</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stats.organizations.map((org) => (
<a
key={org.login}
href={org.html_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<img
src={org.avatar_url}
alt={org.login}
className="w-8 h-8 rounded-full border border-bolt-elements-borderColor"
/>
<div className="flex-1 min-w-0">
<h6 className="text-sm font-medium text-bolt-elements-textPrimary truncate">
{org.name || org.login}
</h6>
<p className="text-xs text-bolt-elements-textSecondary truncate">{org.login}</p>
{org.description && (
<p className="text-xs text-bolt-elements-textTertiary truncate">{org.description}</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
{org.public_repos && (
<span className="flex items-center gap-1">
<div className="i-ph:folder w-3 h-3" />
{org.public_repos}
</span>
)}
{org.followers && (
<span className="flex items-center gap-1">
<div className="i-ph:users w-3 h-3" />
{org.followers}
</span>
)}
</div>
</a>
))}
</div>
</div>
)}
{/* Last Updated */}
<div className="pt-2 border-t border-bolt-elements-borderColor">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {stats.lastUpdated ? new Date(stats.lastUpdated).toLocaleString() : 'Never'}
</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -1,46 +0,0 @@
import React from 'react';
import type { GitHubUserResponse } from '~/types/GitHub';
interface GitHubUserProfileProps {
user: GitHubUserResponse;
className?: string;
}
export function GitHubUserProfile({ user, className = '' }: GitHubUserProfileProps) {
return (
<div
className={`flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg ${className}`}
>
<img
src={user.avatar_url}
alt={user.login}
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent dark:border-bolt-elements-item-contentAccent"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{user.name || user.login}
</h4>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">@{user.login}</p>
{user.bio && (
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary mt-1">
{user.bio}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:users w-3 h-3" />
{user.followers} followers
</span>
<span className="flex items-center gap-1">
<div className="i-ph:folder w-3 h-3" />
{user.public_repos} public repos
</span>
<span className="flex items-center gap-1">
<div className="i-ph:file-text w-3 h-3" />
{user.public_gists} gists
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,264 +0,0 @@
import React from 'react';
import { Loader2, AlertCircle, CheckCircle, Info, Github } from 'lucide-react';
import { classNames } from '~/utils/classNames';
interface LoadingStateProps {
message?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingState({ message = 'Loading...', size = 'md', className = '' }: LoadingStateProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
return (
<div
className={classNames(
'flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary',
className,
)}
>
<Loader2 className={classNames('animate-spin mb-2', sizeClasses[size])} />
<p className={classNames('text-bolt-elements-textSecondary', textSizeClasses[size])}>{message}</p>
</div>
);
}
interface ErrorStateProps {
title?: string;
message: string;
onRetry?: () => void;
retryLabel?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function ErrorState({
title = 'Error',
message,
onRetry,
retryLabel = 'Try Again',
size = 'md',
className = '',
}: ErrorStateProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
return (
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
<AlertCircle className={classNames('text-red-500 mb-2', sizeClasses[size])} />
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
>
{retryLabel}
</button>
)}
</div>
);
}
interface SuccessStateProps {
title?: string;
message: string;
onAction?: () => void;
actionLabel?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function SuccessState({
title = 'Success',
message,
onAction,
actionLabel = 'Continue',
size = 'md',
className = '',
}: SuccessStateProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
return (
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
<CheckCircle className={classNames('text-green-500 mb-2', sizeClasses[size])} />
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
{onAction && (
<button
onClick={onAction}
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
>
{actionLabel}
</button>
)}
</div>
);
}
interface GitHubConnectionRequiredProps {
onConnect?: () => void;
className?: string;
}
export function GitHubConnectionRequired({ onConnect, className = '' }: GitHubConnectionRequiredProps) {
return (
<div className={classNames('flex flex-col items-center justify-center py-12 text-center', className)}>
<Github className="w-12 h-12 text-bolt-elements-textTertiary mb-4" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">GitHub Connection Required</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-6 max-w-md">
Please connect your GitHub account to access this feature. You'll be able to browse repositories, push code, and
manage your GitHub integration.
</p>
{onConnect && (
<button
onClick={onConnect}
className="px-6 py-3 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors flex items-center gap-2"
>
<Github className="w-4 h-4" />
Connect GitHub
</button>
)}
</div>
);
}
interface InformationStateProps {
title: string;
message: string;
icon?: React.ComponentType<{ className?: string }>;
onAction?: () => void;
actionLabel?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InformationState({
title,
message,
icon = Info,
onAction,
actionLabel = 'Got it',
size = 'md',
className = '',
}: InformationStateProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
return (
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
{React.createElement(icon, { className: classNames('text-blue-500 mb-2', sizeClasses[size]) })}
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
{onAction && (
<button
onClick={onAction}
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
>
{actionLabel}
</button>
)}
</div>
);
}
interface ConnectionTestIndicatorProps {
status: 'success' | 'error' | 'testing' | null;
message?: string;
timestamp?: number;
className?: string;
}
export function ConnectionTestIndicator({ status, message, timestamp, className = '' }: ConnectionTestIndicatorProps) {
if (!status) {
return null;
}
const getStatusColor = () => {
switch (status) {
case 'success':
return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700';
case 'error':
return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700';
case 'testing':
return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700';
default:
return 'bg-gray-50 border-gray-200 dark:bg-gray-900/20 dark:border-gray-700';
}
};
const getStatusIcon = () => {
switch (status) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />;
case 'testing':
return <Loader2 className="w-5 h-5 animate-spin text-blue-600 dark:text-blue-400" />;
default:
return <Info className="w-5 h-5 text-gray-600 dark:text-gray-400" />;
}
};
const getStatusTextColor = () => {
switch (status) {
case 'success':
return 'text-green-800 dark:text-green-200';
case 'error':
return 'text-red-800 dark:text-red-200';
case 'testing':
return 'text-blue-800 dark:text-blue-200';
default:
return 'text-gray-800 dark:text-gray-200';
}
};
return (
<div className={classNames(`p-4 rounded-lg border ${getStatusColor()}`, className)}>
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className={classNames('text-sm font-medium', getStatusTextColor())}>{message || status}</span>
</div>
{timestamp && <p className="text-xs text-gray-500 mt-1">{new Date(timestamp).toLocaleString()}</p>}
</div>
);
}

View File

@@ -1,361 +0,0 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import { formatSize } from '~/utils/formatSize';
import type { GitHubRepoInfo } from '~/types/GitHub';
import {
Star,
GitFork,
Clock,
Lock,
Archive,
GitBranch,
Users,
Database,
Tag,
Heart,
ExternalLink,
Circle,
GitPullRequest,
} from 'lucide-react';
interface RepositoryCardProps {
repository: GitHubRepoInfo;
variant?: 'default' | 'compact' | 'detailed';
onSelect?: () => void;
showHealthScore?: boolean;
showExtendedMetrics?: boolean;
className?: string;
}
export function RepositoryCard({
repository,
variant = 'default',
onSelect,
showHealthScore = false,
showExtendedMetrics = false,
className = '',
}: RepositoryCardProps) {
const daysSinceUpdate = Math.floor((Date.now() - new Date(repository.updated_at).getTime()) / (1000 * 60 * 60 * 24));
const formatTimeAgo = () => {
if (daysSinceUpdate === 0) {
return 'Today';
}
if (daysSinceUpdate === 1) {
return '1 day ago';
}
if (daysSinceUpdate < 7) {
return `${daysSinceUpdate} days ago`;
}
if (daysSinceUpdate < 30) {
return `${Math.floor(daysSinceUpdate / 7)} weeks ago`;
}
return new Date(repository.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const calculateHealthScore = () => {
const hasStars = repository.stargazers_count > 0;
const hasRecentActivity = daysSinceUpdate < 30;
const hasContributors = (repository.contributors_count || 0) > 1;
const hasDescription = !!repository.description;
const hasTopics = (repository.topics || []).length > 0;
const hasLicense = !!repository.license;
const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter(
Boolean,
).length;
const maxScore = 6;
const percentage = Math.round((healthScore / maxScore) * 100);
const getScoreColor = (score: number) => {
if (score >= 5) {
return 'text-green-500';
}
if (score >= 3) {
return 'text-yellow-500';
}
return 'text-red-500';
};
return {
percentage,
color: getScoreColor(healthScore),
score: healthScore,
maxScore,
};
};
const getHealthIndicatorColor = () => {
const isActive = daysSinceUpdate < 7;
const isHealthy = daysSinceUpdate < 30 && !repository.archived && repository.stargazers_count > 0;
if (repository.archived) {
return 'bg-gray-500';
}
if (isActive) {
return 'bg-green-500';
}
if (isHealthy) {
return 'bg-blue-500';
}
return 'bg-yellow-500';
};
const getHealthTitle = () => {
if (repository.archived) {
return 'Archived';
}
if (daysSinceUpdate < 7) {
return 'Very Active';
}
if (daysSinceUpdate < 30 && repository.stargazers_count > 0) {
return 'Healthy';
}
return 'Needs Attention';
};
const health = showHealthScore ? calculateHealthScore() : null;
if (variant === 'compact') {
return (
<button
onClick={onSelect}
className={classNames(
'w-full text-left p-3 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive hover:bg-bolt-elements-background-depth-1 transition-all duration-200',
className,
)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{repository.name}</h4>
{repository.private && <Lock className="w-3 h-3 text-bolt-elements-textTertiary" />}
{repository.fork && <GitFork className="w-3 h-3 text-bolt-elements-textTertiary" />}
{repository.archived && <Archive className="w-3 h-3 text-bolt-elements-textTertiary" />}
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<Star className="w-3 h-3" />
{repository.stargazers_count}
</span>
<span className="flex items-center gap-1">
<GitFork className="w-3 h-3" />
{repository.forks_count}
</span>
</div>
</div>
{repository.description && (
<p className="text-xs text-bolt-elements-textSecondary mb-2 line-clamp-2">{repository.description}</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-bolt-elements-textTertiary">
{repository.language && (
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-current opacity-60" />
{repository.language}
</span>
)}
{repository.size && <span>{formatSize(repository.size * 1024)}</span>}
</div>
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary">
<Clock className="w-3 h-3" />
{formatTimeAgo()}
</span>
</div>
</button>
);
}
const Component = onSelect ? 'button' : 'div';
const interactiveProps = onSelect
? {
onClick: onSelect,
className: classNames(
'group cursor-pointer hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200',
className,
),
}
: { className };
return (
<Component
{...interactiveProps}
className={classNames(
'block p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor relative',
interactiveProps.className,
)}
>
{/* Repository Health Indicator */}
{variant === 'detailed' && (
<div
className={`absolute top-2 right-2 w-2 h-2 rounded-full ${getHealthIndicatorColor()}`}
title={`Repository Health: ${getHealthTitle()}`}
/>
)}
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-bolt-elements-icon-tertiary" />
<h5
className={classNames(
'text-sm font-medium text-bolt-elements-textPrimary',
onSelect && 'group-hover:text-bolt-elements-item-contentAccent transition-colors',
)}
>
{repository.name}
</h5>
{repository.fork && (
<span title="Forked repository">
<GitFork className="w-3 h-3 text-bolt-elements-textTertiary" />
</span>
)}
{repository.archived && (
<span title="Archived repository">
<Archive className="w-3 h-3 text-bolt-elements-textTertiary" />
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<Star className="w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repository.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<GitFork className="w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repository.forks_count.toLocaleString()}
</span>
{showExtendedMetrics && repository.issues_count !== undefined && (
<span className="flex items-center gap-1" title="Open Issues">
<Circle className="w-3.5 h-3.5 text-bolt-elements-icon-error" />
{repository.issues_count}
</span>
)}
{showExtendedMetrics && repository.pull_requests_count !== undefined && (
<span className="flex items-center gap-1" title="Pull Requests">
<GitPullRequest className="w-3.5 h-3.5 text-bolt-elements-icon-success" />
{repository.pull_requests_count}
</span>
)}
</div>
</div>
<div className="space-y-2">
{repository.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repository.description}</p>
)}
{/* Repository metrics bar */}
<div className="flex items-center gap-2 text-xs">
{repository.license && (
<span className="px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary">
{repository.license.spdx_id || repository.license.name}
</span>
)}
{repository.topics &&
repository.topics.slice(0, 2).map((topic) => (
<span
key={topic}
className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
{topic}
</span>
))}
{repository.archived && (
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400">
Archived
</span>
)}
{repository.fork && (
<span className="px-2 py-0.5 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400">
Fork
</span>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<GitBranch className="w-3.5 h-3.5" />
{repository.default_branch}
</span>
{showExtendedMetrics && repository.branches_count && (
<span className="flex items-center gap-1" title="Total Branches">
<GitFork className="w-3.5 h-3.5" />
{repository.branches_count}
</span>
)}
{showExtendedMetrics && repository.contributors_count && (
<span className="flex items-center gap-1" title="Contributors">
<Users className="w-3.5 h-3.5" />
{repository.contributors_count}
</span>
)}
{repository.size && (
<span className="flex items-center gap-1" title="Size">
<Database className="w-3.5 h-3.5" />
{(repository.size / 1024).toFixed(1)}MB
</span>
)}
<span className="flex items-center gap-1" title="Last Updated">
<Clock className="w-3.5 h-3.5" />
{formatTimeAgo()}
</span>
{repository.topics && repository.topics.length > 0 && (
<span className="flex items-center gap-1" title={`Topics: ${repository.topics.join(', ')}`}>
<Tag className="w-3.5 h-3.5" />
{repository.topics.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Repository Health Score */}
{health && (
<div
className="flex items-center gap-1"
title={`Health Score: ${health.percentage}% (${health.score}/${health.maxScore})`}
>
<Heart className={`w-3.5 h-3.5 ${health.color}`} />
<span className={`text-xs font-medium ${health.color}`}>{health.percentage}%</span>
</div>
)}
{onSelect && (
<span
className={classNames(
'flex items-center gap-1 ml-2 transition-colors',
'group-hover:text-bolt-elements-item-contentAccent',
)}
>
<ExternalLink className="w-3.5 h-3.5" />
View
</span>
)}
</div>
</div>
</div>
</Component>
);
}

View File

@@ -1,11 +0,0 @@
export { RepositoryCard } from './RepositoryCard';
// GitHubDialog components not yet implemented
export {
LoadingState,
ErrorState,
SuccessState,
GitHubConnectionRequired,
InformationState,
ConnectionTestIndicator,
} from './GitHubStateIndicators';

View File

@@ -1,305 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { useGitLabConnection } from '~/lib/hooks';
import GitLabConnection from './components/GitLabConnection';
import { StatsDisplay } from './components/StatsDisplay';
import { RepositoryList } from './components/RepositoryList';
// GitLab logo SVG component
const GitLabLogo = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
);
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
export default function GitLabTab() {
const { connection, isConnected, isLoading, error, testConnection, refreshStats } = useGitLabConnection();
const [connectionTest, setConnectionTest] = useState<ConnectionTestResult | null>(null);
const [isRefreshingStats, setIsRefreshingStats] = useState(false);
const handleTestConnection = async () => {
if (!connection?.user) {
setConnectionTest({
status: 'error',
message: 'No connection established',
timestamp: Date.now(),
});
return;
}
setConnectionTest({
status: 'testing',
message: 'Testing connection...',
});
try {
const isValid = await testConnection();
if (isValid) {
setConnectionTest({
status: 'success',
message: `Connected successfully as ${connection.user.username}`,
timestamp: Date.now(),
});
} else {
setConnectionTest({
status: 'error',
message: 'Connection test failed',
timestamp: Date.now(),
});
}
} catch (error) {
setConnectionTest({
status: 'error',
message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: Date.now(),
});
}
};
// Loading state for initial connection check
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<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>
</div>
);
}
// Error state for connection issues
if (error && !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<div className="text-sm text-red-600 dark:text-red-400 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
{error}
</div>
</div>
);
}
// Not connected state
if (!isConnected || !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<p className="text-sm text-bolt-elements-textSecondary">
Connect your GitLab account to enable advanced repository management features, statistics, and seamless
integration.
</p>
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<motion.div
className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitLab Integration
</h2>
</div>
<div className="flex items-center gap-2">
{connection?.rateLimit && (
<div className="flex items-center gap-2 px-3 py-1 bg-bolt-elements-background-depth-1 rounded-lg text-xs">
<div className="i-ph:cloud w-4 h-4 text-bolt-elements-textSecondary" />
<span className="text-bolt-elements-textSecondary">
API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
</span>
</div>
)}
</div>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your GitLab integration with advanced repository features and comprehensive statistics
</p>
{/* Connection Test Results */}
{connectionTest && (
<div
className={`p-3 rounded-lg border ${
connectionTest.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: connectionTest.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-4 h-4 ${
connectionTest.status === 'success'
? 'text-green-600'
: connectionTest.status === 'error'
? 'text-red-600'
: 'text-blue-600'
}`}
>
{connectionTest.status === 'success' ? (
<div className="i-ph:check-circle" />
) : connectionTest.status === 'error' ? (
<div className="i-ph:x-circle" />
) : (
<div className="i-ph:spinner animate-spin" />
)}
</div>
<span
className={`text-sm ${
connectionTest.status === 'success'
? 'text-green-800 dark:text-green-200'
: connectionTest.status === 'error'
? 'text-red-800 dark:text-red-200'
: 'text-blue-800 dark:text-blue-200'
}`}
>
{connectionTest.message}
</span>
</div>
</div>
)}
{/* GitLab Connection Component */}
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
{/* User Profile Section */}
{connection?.user && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 rounded-lg">
<div className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent flex items-center justify-center bg-bolt-elements-background-depth-2 overflow-hidden">
{connection.user.avatar_url &&
connection.user.avatar_url !== 'null' &&
connection.user.avatar_url !== '' ? (
<img
src={connection.user.avatar_url}
alt={connection.user.username}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML = (connection.user?.name || connection.user?.username || 'U')
.charAt(0)
.toUpperCase();
parent.classList.add(
'text-white',
'font-semibold',
'text-sm',
'flex',
'items-center',
'justify-center',
);
}
}}
/>
) : (
<div className="w-full h-full rounded-full bg-bolt-elements-item-contentAccent flex items-center justify-center text-white font-semibold text-sm">
{(connection.user?.name || connection.user?.username || 'U').charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.name || connection.user?.username}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user?.username}</p>
</div>
</div>
</motion.div>
)}
{/* GitLab Stats Section */}
{connection?.stats && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<h3 className="text-base font-medium text-bolt-elements-textPrimary mb-4">Statistics</h3>
<StatsDisplay
stats={connection.stats}
onRefresh={async () => {
setIsRefreshingStats(true);
try {
await refreshStats();
} catch (error) {
console.error('Failed to refresh stats:', error);
} finally {
setIsRefreshingStats(false);
}
}}
isRefreshing={isRefreshingStats}
/>
</motion.div>
)}
{/* GitLab Repositories Section */}
{connection?.stats?.projects && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<RepositoryList
repositories={connection.stats.projects}
onRefresh={async () => {
setIsRefreshingStats(true);
try {
await refreshStats();
} catch (error) {
console.error('Failed to refresh repositories:', error);
} finally {
setIsRefreshingStats(false);
}
}}
isRefreshing={isRefreshingStats}
/>
</motion.div>
)}
</div>
);
}

View File

@@ -1,186 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { useGitLabConnection } from '~/lib/hooks';
interface GitLabAuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function GitLabAuthDialog({ isOpen, onClose }: GitLabAuthDialogProps) {
const { isConnecting, error, connect } = useGitLabConnection();
const [token, setToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
if (!token.trim()) {
toast.error('Please enter your GitLab access token');
return;
}
try {
await connect(token, gitlabUrl);
toast.success('Successfully connected to GitLab!');
setToken('');
onClose();
} catch (error) {
// Error handling is done in the hook
console.error('GitLab connect failed:', error);
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[10000]" />
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
aria-describedby="gitlab-auth-description"
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-4">
Connect to GitLab
</Dialog.Title>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 flex items-center justify-center text-orange-500">
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
</div>
<div>
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
GitLab Connection
</h3>
<p
id="gitlab-auth-description"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
>
Connect your GitLab account to deploy your projects
</p>
</div>
</div>
<form onSubmit={handleConnect} className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
GitLab URL
</label>
<input
type="url"
value={gitlabUrl}
onChange={(e) => setGitlabUrl(e.target.value)}
disabled={isConnecting}
placeholder="https://gitlab.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
'focus:outline-none focus:ring-2 focus:ring-orange-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
/>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting}
placeholder="Enter your GitLab access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
'focus:outline-none focus:ring-2 focus:ring-orange-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
required
/>
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
<a
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-600 hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-3 h-3" />
</a>
<span className="mx-2"></span>
<span>Required scopes: api, read_repository</span>
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<motion.button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isConnecting}
>
Cancel
</motion.button>
<motion.button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm inline-flex items-center gap-2',
'bg-orange-500 text-white hover:bg-orange-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
whileHover={!isConnecting && token.trim() ? { scale: 1.02 } : {}}
whileTap={!isConnecting && token.trim() ? { scale: 0.98 } : {}}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect to GitLab
</>
)}
</motion.button>
</div>
</form>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,253 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import { useGitLabConnection } from '~/lib/hooks';
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
interface GitLabConnectionProps {
connectionTest: ConnectionTestResult | null;
onTestConnection: () => void;
}
export default function GitLabConnection({ connectionTest, onTestConnection }: GitLabConnectionProps) {
const { isConnected, isConnecting, connection, error, connect, disconnect } = useGitLabConnection();
const [token, setToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
console.log('GitLab connect attempt:', {
token: token ? `${token.substring(0, 10)}...` : 'empty',
gitlabUrl,
tokenLength: token.length,
});
if (!token.trim()) {
console.log('Token is empty, not attempting connection');
return;
}
try {
console.log('Calling connect function...');
await connect(token, gitlabUrl);
console.log('Connect function completed successfully');
setToken(''); // Clear token on successful connection
} catch (error) {
console.error('GitLab connect failed:', error);
// Error handling is done in the hook
}
};
const handleDisconnect = () => {
disconnect();
toast.success('Disconnected from GitLab');
};
return (
<motion.div
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg"
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 justify-between">
<div className="flex items-center gap-2">
<div className="w-5 h-5 text-orange-600">
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
</div>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitLab Connection</h3>
</div>
</div>
{!isConnected && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">VITE_GITLAB_ACCESS_TOKEN</code>{' '}
environment variable to connect automatically.
</p>
<p>
For self-hosted GitLab instances, also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">
VITE_GITLAB_URL=https://your-gitlab-instance.com
</code>
</p>
</div>
)}
<form onSubmit={handleConnect}>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitLab URL</label>
<input
type="text"
value={gitlabUrl}
onChange={(e) => setGitlabUrl(e.target.value)}
disabled={isConnecting || isConnected}
placeholder="https://gitlab.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting || isConnected}
placeholder="Enter your GitLab access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<span className="mx-2"></span>
<span>Required scopes: api, read_repository</span>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex items-center justify-between">
{!isConnected ? (
<>
<button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#FC6D26] text-white',
'hover:bg-[#E24329] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
<button
type="button"
onClick={() =>
console.log('Manual test:', { token: token ? `${token.substring(0, 10)}...` : 'empty', gitlabUrl })
}
className="px-4 py-2 rounded-lg text-sm bg-gray-500 text-white hover:bg-gray-600"
>
Test Values
</button>
</>
) : (
<>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<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 GitLab
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
window.open(
`${connection?.gitlabUrl || 'https://gitlab.com'}/dashboard`,
'_blank',
'noopener,noreferrer',
)
}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout w-4 h-4" />
Dashboard
</Button>
<Button
onClick={onTestConnection}
disabled={connectionTest?.status === 'testing'}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{connectionTest?.status === 'testing' ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Testing...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Test Connection
</>
)}
</Button>
</div>
</div>
</>
)}
</div>
</form>
</div>
</motion.div>
);
}

View File

@@ -1,358 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { BranchSelector } from '~/components/ui/BranchSelector';
import { RepositoryCard } from './RepositoryCard';
import type { GitLabProjectInfo } from '~/types/GitLab';
import { useGitLabConnection } from '~/lib/hooks';
import { classNames } from '~/utils/classNames';
import { Search, RefreshCw, GitBranch, Calendar, Filter } from 'lucide-react';
interface GitLabRepositorySelectorProps {
onClone?: (repoUrl: string, branch?: string) => void;
className?: string;
}
type SortOption = 'updated' | 'stars' | 'name' | 'created';
type FilterOption = 'all' | 'owned' | 'member';
export function GitLabRepositorySelector({ onClone, className }: GitLabRepositorySelectorProps) {
const { connection, isConnected } = useGitLabConnection();
const [repositories, setRepositories] = useState<GitLabProjectInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('updated');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<GitLabProjectInfo | null>(null);
const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
const REPOS_PER_PAGE = 12;
// Fetch repositories
const fetchRepositories = async (refresh = false) => {
if (!isConnected || !connection?.token) {
return;
}
const loadingState = refresh ? setIsRefreshing : setIsLoading;
loadingState(true);
setError(null);
try {
const response = await fetch('/api/gitlab-projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: connection.token,
gitlabUrl: connection.gitlabUrl || 'https://gitlab.com',
}),
});
if (!response.ok) {
const errorData: any = await response.json().catch(() => ({ error: 'Failed to fetch repositories' }));
throw new Error(errorData.error || 'Failed to fetch repositories');
}
const data: any = await response.json();
setRepositories(data.projects || []);
} catch (err) {
console.error('Failed to fetch GitLab repositories:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
// Fallback to empty array on error
setRepositories([]);
} finally {
loadingState(false);
}
};
// Filter and search repositories
const filteredRepositories = useMemo(() => {
if (!repositories) {
return [];
}
const filtered = repositories.filter((repo: GitLabProjectInfo) => {
// Search filter
const matchesSearch =
!searchQuery ||
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase());
// Type filter
let matchesFilter = true;
switch (filterBy) {
case 'owned':
// This would need owner information from the API response
matchesFilter = true; // For now, show all
break;
case 'member':
// This would need member information from the API response
matchesFilter = true; // For now, show all
break;
case 'all':
default:
matchesFilter = true;
break;
}
return matchesSearch && matchesFilter;
});
// Sort repositories
filtered.sort((a: GitLabProjectInfo, b: GitLabProjectInfo) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'stars':
return b.star_count - a.star_count;
case 'created':
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); // Using updated_at as proxy
case 'updated':
default:
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
}
});
return filtered;
}, [repositories, searchQuery, sortBy, filterBy]);
// Pagination
const totalPages = Math.ceil(filteredRepositories.length / REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, startIndex + REPOS_PER_PAGE);
const handleRefresh = () => {
fetchRepositories(true);
};
const handleCloneRepository = (repo: GitLabProjectInfo) => {
setSelectedRepo(repo);
setIsBranchSelectorOpen(true);
};
const handleBranchSelect = (branch: string) => {
if (onClone && selectedRepo) {
onClone(selectedRepo.http_url_to_repo, branch);
}
setSelectedRepo(null);
};
const handleCloseBranchSelector = () => {
setIsBranchSelectorOpen(false);
setSelectedRepo(null);
};
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, sortBy, filterBy]);
// Fetch repositories when connection is ready
useEffect(() => {
if (isConnected && connection?.token) {
fetchRepositories();
}
}, [isConnected, connection?.token]);
if (!isConnected || !connection) {
return (
<div className="text-center p-8">
<p className="text-bolt-elements-textSecondary mb-4">Please connect to GitLab first to browse repositories</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh Connection
</Button>
</div>
);
}
if (error && !repositories.length) {
return (
<div className="text-center p-8">
<div className="text-red-500 mb-4">
<GitBranch className="w-12 h-12 mx-auto mb-2" />
<p className="font-medium">Failed to load repositories</p>
<p className="text-sm text-bolt-elements-textSecondary mt-1">{error}</p>
</div>
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
Try Again
</Button>
</div>
);
}
if (isLoading && !repositories.length) {
return (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="animate-spin w-8 h-8 border-2 border-bolt-elements-borderColorActive border-t-transparent rounded-full" />
<p className="text-sm text-bolt-elements-textSecondary">Loading repositories...</p>
</div>
);
}
if (!repositories.length && !isLoading) {
return (
<div className="text-center p-8">
<GitBranch className="w-12 h-12 text-bolt-elements-textTertiary mx-auto mb-4" />
<p className="text-bolt-elements-textSecondary mb-4">No repositories found</p>
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
);
}
return (
<motion.div
className={classNames('space-y-6', className)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header with stats */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Select Repository to Clone</h3>
<p className="text-sm text-bolt-elements-textSecondary">
{filteredRepositories.length} of {repositories.length} repositories
</p>
</div>
<Button
onClick={handleRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<RefreshCw className={classNames('w-4 h-4', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
{error && repositories.length > 0 && (
<div className="p-3 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-700">
<p className="text-sm text-yellow-800 dark:text-yellow-200">Warning: {error}. Showing cached data.</p>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bolt-elements-textTertiary" />
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
</div>
{/* Sort */}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="updated">Recently updated</option>
<option value="stars">Most starred</option>
<option value="name">Name (A-Z)</option>
<option value="created">Recently created</option>
</select>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="all">All repositories</option>
<option value="owned">Owned repositories</option>
<option value="member">Member repositories</option>
</select>
</div>
</div>
{/* Repository Grid */}
{currentRepositories.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentRepositories.map((repo) => (
<div key={repo.id} className="relative">
<RepositoryCard repo={repo} onClone={() => handleCloneRepository(repo)} />
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8">
<p className="text-bolt-elements-textSecondary">No repositories found matching your search criteria.</p>
</div>
)}
{/* Branch Selector Modal */}
{selectedRepo && (
<BranchSelector
provider="gitlab"
repoOwner={selectedRepo.path_with_namespace.split('/')[0]}
repoName={selectedRepo.path_with_namespace.split('/')[1]}
projectId={selectedRepo.id}
token={connection?.token || ''}
gitlabUrl={connection?.gitlabUrl}
defaultBranch={selectedRepo.default_branch}
onBranchSelect={handleBranchSelect}
onClose={handleCloseBranchSelector}
isOpen={isBranchSelectorOpen}
/>
)}
</motion.div>
);
}

View File

@@ -1,79 +0,0 @@
import React from 'react';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryCardProps {
repo: GitLabProjectInfo;
onClone?: (repo: GitLabProjectInfo) => void;
}
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.http_url_to_repo}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info" />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.star_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<div className="flex items-center gap-2 ml-auto">
{onClone && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClone(repo);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
title="Clone repository"
>
<div className="i-ph:git-branch w-3.5 h-3.5" />
Clone
</button>
)}
<span className="flex items-center gap-1 group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</div>
</a>
);
}

View File

@@ -1,142 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { RepositoryCard } from './RepositoryCard';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryListProps {
repositories: GitLabProjectInfo[];
onClone?: (repo: GitLabProjectInfo) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const MAX_REPOS_PER_PAGE = 20;
export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isSearching, setIsSearching] = useState(false);
const filteredRepositories = useMemo(() => {
if (!searchQuery) {
return repositories;
}
setIsSearching(true);
const filtered = repositories.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
(repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())),
);
setIsSearching(false);
return filtered;
}, [repositories, searchQuery]);
const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
const endIndex = startIndex + MAX_REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
const handleSearch = (query: string) => {
setSearchQuery(query);
setCurrentPage(1); // Reset to first page when searching
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({filteredRepositories.length})
</h4>
{onRefresh && (
<Button
onClick={onRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isRefreshing ? (
<div className="i-ph:spinner animate-spin w-4 h-4" />
) : (
<div className="i-ph:arrows-clockwise w-4 h-4" />
)}
Refresh
</Button>
)}
</div>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<div className="i-ph:spinner animate-spin w-4 h-4 text-bolt-elements-textSecondary" />
) : (
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textSecondary" />
)}
</div>
</div>
{/* Repository Grid */}
<div className="space-y-4">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
{searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} onClone={onClone} />
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
<div className="i-ph:caret-left w-4 h-4" />
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
<div className="i-ph:caret-right w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import type { GitLabStats } from '~/types/GitLab';
interface StatsDisplayProps {
stats: GitLabStats;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
return (
<div className="space-y-4">
{/* Repository Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: stats.publicProjects,
},
{
label: 'Private Repos',
value: stats.privateProjects,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
{/* Contribution Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Followers',
value: stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value}
</span>
</div>
))}
</div>
</div>
<div className="pt-2 border-t border-bolt-elements-borderColor">
<div className="flex items-center justify-between">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
</span>
{onRefresh && (
<Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm" className="text-xs">
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,4 +0,0 @@
export { default as GitLabConnection } from './GitLabConnection';
export { RepositoryCard } from './RepositoryCard';
export { RepositoryList } from './RepositoryList';
export { StatsDisplay } from './StatsDisplay';

View File

@@ -1,99 +0,0 @@
import type { MCPServer } from '~/lib/services/mcpService';
import McpStatusBadge from '~/components/@settings/tabs/mcp/McpStatusBadge';
import McpServerListItem from '~/components/@settings/tabs/mcp/McpServerListItem';
type McpServerListProps = {
serverEntries: [string, MCPServer][];
expandedServer: string | null;
checkingServers: boolean;
onlyShowAvailableServers?: boolean;
toggleServerExpanded: (serverName: string) => void;
};
export default function McpServerList({
serverEntries,
expandedServer,
checkingServers,
onlyShowAvailableServers = false,
toggleServerExpanded,
}: McpServerListProps) {
if (serverEntries.length === 0) {
return <p className="text-sm text-bolt-elements-textSecondary">No MCP servers configured</p>;
}
const filteredEntries = onlyShowAvailableServers
? serverEntries.filter(([, s]) => s.status === 'available')
: serverEntries;
return (
<div className="space-y-2">
{filteredEntries.map(([serverName, mcpServer]) => {
const isAvailable = mcpServer.status === 'available';
const isExpanded = expandedServer === serverName;
const serverTools = isAvailable ? Object.entries(mcpServer.tools) : [];
return (
<div key={serverName} className="flex flex-col p-2 rounded-md bg-bolt-elements-background-depth-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
onClick={() => toggleServerExpanded(serverName)}
className="flex items-center gap-1.5 text-bolt-elements-textPrimary"
aria-expanded={isExpanded}
>
<div
className={`i-ph:${isExpanded ? 'caret-down' : 'caret-right'} w-3 h-3 transition-transform duration-150`}
/>
<span className="font-medium truncate text-left">{serverName}</span>
</div>
<div className="flex-1 min-w-0 truncate">
{mcpServer.config.type === 'sse' || mcpServer.config.type === 'streamable-http' ? (
<span className="text-xs text-bolt-elements-textSecondary truncate">{mcpServer.config.url}</span>
) : (
<span className="text-xs text-bolt-elements-textSecondary truncate">
{mcpServer.config.command} {mcpServer.config.args?.join(' ')}
</span>
)}
</div>
</div>
<div className="ml-2 flex-shrink-0">
{checkingServers ? (
<McpStatusBadge status="checking" />
) : (
<McpStatusBadge status={isAvailable ? 'available' : 'unavailable'} />
)}
</div>
</div>
{/* Error message */}
{!isAvailable && mcpServer.error && (
<div className="mt-1.5 ml-6 text-xs text-red-600 dark:text-red-400">Error: {mcpServer.error}</div>
)}
{/* Tool list */}
{isExpanded && isAvailable && (
<div className="mt-2">
<div className="text-bolt-elements-textSecondary text-xs font-medium ml-1 mb-1.5">Available Tools:</div>
{serverTools.length === 0 ? (
<div className="ml-4 text-xs text-bolt-elements-textSecondary">No tools available</div>
) : (
<div className="mt-1 space-y-2">
{serverTools.map(([toolName, toolSchema]) => (
<McpServerListItem
key={`${serverName}-${toolName}`}
toolName={toolName}
toolSchema={toolSchema}
/>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,70 +0,0 @@
import type { Tool } from 'ai';
type ParameterProperty = {
type?: string;
description?: string;
};
type ToolParameters = {
jsonSchema: {
properties?: Record<string, ParameterProperty>;
required?: string[];
};
};
type McpToolProps = {
toolName: string;
toolSchema: Tool;
};
export default function McpServerListItem({ toolName, toolSchema }: McpToolProps) {
if (!toolSchema) {
return null;
}
const parameters = (toolSchema.parameters as ToolParameters)?.jsonSchema.properties || {};
const requiredParams = (toolSchema.parameters as ToolParameters)?.jsonSchema.required || [];
return (
<div className="mt-2 ml-4 p-3 rounded-md bg-bolt-elements-background-depth-2 text-xs">
<div className="flex flex-col gap-1.5">
<h3 className="text-bolt-elements-textPrimary font-semibold truncate" title={toolName}>
{toolName}
</h3>
<p className="text-bolt-elements-textSecondary">{toolSchema.description || 'No description available'}</p>
{Object.keys(parameters).length > 0 && (
<div className="mt-2.5">
<h4 className="text-bolt-elements-textSecondary font-semibold mb-1.5">Parameters:</h4>
<ul className="ml-1 space-y-2">
{Object.entries(parameters).map(([paramName, paramDetails]) => (
<li key={paramName} className="break-words">
<div className="flex items-start">
<span className="font-medium text-bolt-elements-textPrimary">
{paramName}
{requiredParams.includes(paramName) && (
<span className="text-red-600 dark:text-red-400 ml-1">*</span>
)}
</span>
<span className="mx-2 text-bolt-elements-textSecondary"></span>
<div className="flex-1">
{paramDetails.type && (
<span className="text-bolt-elements-textSecondary italic">{paramDetails.type}</span>
)}
{paramDetails.description && (
<div className="mt-0.5 text-bolt-elements-textSecondary">{paramDetails.description}</div>
)}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { useMemo } from 'react';
export default function McpStatusBadge({ status }: { status: 'checking' | 'available' | 'unavailable' }) {
const { styles, label, icon, ariaLabel } = useMemo(() => {
const base = 'px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 transition-colors';
const config = {
checking: {
styles: `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/80 dark:text-blue-200`,
label: 'Checking...',
ariaLabel: 'Checking server status',
icon: <span className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-current animate-spin" aria-hidden="true" />,
},
available: {
styles: `${base} bg-green-100 text-green-800 dark:bg-green-900/80 dark:text-green-200`,
label: 'Available',
ariaLabel: 'Server available',
icon: <span className="i-ph:check-circle w-3 h-3 text-current" aria-hidden="true" />,
},
unavailable: {
styles: `${base} bg-red-100 text-red-800 dark:bg-red-900/80 dark:text-red-200`,
label: 'Unavailable',
ariaLabel: 'Server unavailable',
icon: <span className="i-ph:warning-circle w-3 h-3 text-current" aria-hidden="true" />,
},
};
return config[status];
}, [status]);
return (
<span className={styles} role="status" aria-live="polite" aria-label={ariaLabel}>
{icon}
{label}
</span>
);
}

View File

@@ -1,239 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { classNames } from '~/utils/classNames';
import type { MCPConfig } from '~/lib/services/mcpService';
import { toast } from 'react-toastify';
import { useMCPStore } from '~/lib/stores/mcp';
import McpServerList from '~/components/@settings/tabs/mcp/McpServerList';
const EXAMPLE_MCP_CONFIG: MCPConfig = {
mcpServers: {
everything: {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-everything'],
},
deepwiki: {
type: 'streamable-http',
url: 'https://mcp.deepwiki.com/mcp',
},
'local-sse': {
type: 'sse',
url: 'http://localhost:8000/sse',
headers: {
Authorization: 'Bearer mytoken123',
},
},
},
};
export default function McpTab() {
const settings = useMCPStore((state) => state.settings);
const isInitialized = useMCPStore((state) => state.isInitialized);
const serverTools = useMCPStore((state) => state.serverTools);
const initialize = useMCPStore((state) => state.initialize);
const updateSettings = useMCPStore((state) => state.updateSettings);
const checkServersAvailabilities = useMCPStore((state) => state.checkServersAvailabilities);
const [isSaving, setIsSaving] = useState(false);
const [mcpConfigText, setMCPConfigText] = useState('');
const [maxLLMSteps, setMaxLLMSteps] = useState(1);
const [error, setError] = useState<string | null>(null);
const [isCheckingServers, setIsCheckingServers] = useState(false);
const [expandedServer, setExpandedServer] = useState<string | null>(null);
useEffect(() => {
if (!isInitialized) {
initialize().catch((err) => {
setError(`Failed to initialize MCP settings: ${err instanceof Error ? err.message : String(err)}`);
toast.error('Failed to load MCP configuration');
});
}
}, [isInitialized]);
useEffect(() => {
setMCPConfigText(JSON.stringify(settings.mcpConfig, null, 2));
setMaxLLMSteps(settings.maxLLMSteps);
setError(null);
}, [settings]);
const parsedConfig = useMemo(() => {
try {
setError(null);
return JSON.parse(mcpConfigText) as MCPConfig;
} catch (e) {
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}, [mcpConfigText]);
const handleMaxLLMCallChange = (value: string) => {
setMaxLLMSteps(parseInt(value, 10));
};
const handleSave = async () => {
if (!parsedConfig) {
return;
}
setIsSaving(true);
try {
await updateSettings({
mcpConfig: parsedConfig,
maxLLMSteps,
});
toast.success('MCP configuration saved');
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save configuration');
toast.error('Failed to save MCP configuration');
} finally {
setIsSaving(false);
}
};
const handleLoadExample = () => {
setMCPConfigText(JSON.stringify(EXAMPLE_MCP_CONFIG, null, 2));
setError(null);
};
const checkServerAvailability = async () => {
if (serverEntries.length === 0) {
return;
}
setIsCheckingServers(true);
setError(null);
try {
await checkServersAvailabilities();
} catch (e) {
setError(`Failed to check server availability: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setIsCheckingServers(false);
}
};
const toggleServerExpanded = (serverName: string) => {
setExpandedServer(expandedServer === serverName ? null : serverName);
};
const serverEntries = useMemo(() => Object.entries(serverTools), [serverTools]);
return (
<div className="max-w-2xl mx-auto space-y-6">
<section aria-labelledby="server-status-heading">
<div className="flex justify-between items-center mb-3">
<h2 className="text-base font-medium text-bolt-elements-textPrimary">MCP Servers Configured</h2>{' '}
<button
onClick={checkServerAvailability}
disabled={isCheckingServers || !parsedConfig || serverEntries.length === 0}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
'text-bolt-elements-textPrimary',
'transition-all duration-200',
'flex items-center gap-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isCheckingServers ? (
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
) : (
<div className="i-ph:arrow-counter-clockwise w-3 h-3" />
)}
Check availability
</button>
</div>
<McpServerList
checkingServers={isCheckingServers}
expandedServer={expandedServer}
serverEntries={serverEntries}
toggleServerExpanded={toggleServerExpanded}
/>
</section>
<section aria-labelledby="config-section-heading">
<h2 className="text-base font-medium text-bolt-elements-textPrimary mb-3">Configuration</h2>
<div className="space-y-4">
<div>
<label htmlFor="mcp-config" className="block text-sm text-bolt-elements-textSecondary mb-2">
Configuration JSON
</label>
<textarea
id="mcp-config"
value={mcpConfigText}
onChange={(e) => setMCPConfigText(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm font-mono h-72',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border',
error ? 'border-bolt-elements-icon-error' : 'border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus',
)}
/>
</div>
<div>{error && <p className="mt-2 mb-2 text-sm text-bolt-elements-icon-error">{error}</p>}</div>
<div>
<label htmlFor="max-llm-steps" className="block text-sm text-bolt-elements-textSecondary mb-2">
Maximum number of sequential LLM calls (steps)
</label>
<input
id="max-llm-steps"
type="number"
placeholder="Maximum number of sequential LLM calls"
min="1"
max="20"
value={maxLLMSteps}
onChange={(e) => handleMaxLLMCallChange(e.target.value)}
className="w-full px-3 py-2 text-bolt-elements-textPrimary text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
The MCP configuration format is identical to the one used in Claude Desktop.
<a
href="https://modelcontextprotocol.io/examples"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-link hover:underline inline-flex items-center gap-1"
>
View example servers
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
</section>
<div className="flex flex-wrap justify-between gap-3 mt-6">
<button
onClick={handleLoadExample}
className="px-4 py-2 rounded-lg text-sm border border-bolt-elements-borderColor
bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary
hover:bg-bolt-elements-background-depth-3"
>
Load Example
</button>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={isSaving || !parsedConfig}
aria-disabled={isSaving || !parsedConfig}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent',
'hover:bg-bolt-elements-item-backgroundActive',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
<div className="i-ph:floppy-disk w-4 h-4" />
{isSaving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,990 +0,0 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
import {
CloudIcon,
BuildingLibraryIcon,
ClockIcon,
CodeBracketIcon,
CheckCircleIcon,
XCircleIcon,
TrashIcon,
ArrowPathIcon,
LockClosedIcon,
LockOpenIcon,
RocketLaunchIcon,
ChartBarIcon,
CogIcon,
} from '@heroicons/react/24/outline';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { formatDistanceToNow } from 'date-fns';
import { Badge } from '~/components/ui/Badge';
// Add the Netlify logo SVG component at the top of the file
const NetlifyLogo = () => (
<svg viewBox="0 0 40 40" className="w-5 h-5">
<path
fill="currentColor"
d="M28.589 14.135l-.014-.006c-.008-.003-.016-.006-.023-.013a.11.11 0 0 1-.028-.093l.773-4.726 3.625 3.626-3.77 1.604a.083.083 0 0 1-.033.006h-.015c-.005-.003-.01-.007-.02-.017a1.716 1.716 0 0 0-.495-.381zm5.258-.288l3.876 3.876c.805.806 1.208 1.208 1.674 1.355a2 2 0 0 1 1.206 0c.466-.148.869-.55 1.674-1.356L8.73 28.73l2.349-3.643c.011-.018.022-.034.04-.047.025-.018.061-.01.091 0a2.434 2.434 0 0 0 1.638-.083c.027-.01.054-.017.075.002a.19.19 0 0 1 .028.032L21.95 38.05zM7.863 27.863L5.8 25.8l4.074-1.738a.084.084 0 0 1 .033-.007c.034 0 .054.034.072.065a2.91 2.91 0 0 0 .13.184l.013.016c.012.017.004.034-.008.05l-2.25 3.493zm-2.976-2.976l-2.61-2.61c-.444-.444-.766-.766-.99-1.043l7.936 1.646a.84.84 0 0 0 .03.005c.049.008.103.017.103.063 0 .05-.059.073-.109.092l-.023.01-4.337 1.837zM.831 19.892a2 2 0 0 1 .09-.495c.148-.466.55-.868 1.356-1.674l3.34-3.34a2175.525 2175.525 0 0 0 4.626 6.687c.027.036.057.076.026.106-.146.161-.292.337-.395.528a.16.16 0 0 1-.05.062c-.013.008-.027.005-.042.002H9.78L.831 19.892zm5.68-6.403l4.491-4.491c.422.185 1.958.834 3.332 1.414 1.04.44 1.988.84 2.286.97.03.012.057.024.07.054.008.018.004.041 0 .06a2.003 2.003 0 0 0 .523 1.828c.03.03 0 .073-.026.11l-.014.021-4.56 7.063c-.012.02-.023.037-.043.05-.024.015-.058.008-.086.001a2.274 2.274 0 0 0-.543-.074c-.164 0-.342.03-.522.063h-.001c-.02.003-.038.007-.054-.005a.21.21 0 0 1-.045-.051l-4.808-7.013zm5.398-5.398l5.814-5.814c.805-.805 1.208-1.208 1.674-1.355a2 2 0 0 1 1.206 0c.466.147.869.55 1.674 1.355l1.26 1.26-4.135 6.404a.155.155 0 0 1-.041.048c-.025.017-.06.01-.09 0a2.097 2.097 0 0 0-1.92.37c-.027.028-.067.012-.101-.003-.54-.235-4.74-2.01-5.341-2.265zm12.506-3.676l3.818 3.818-.92 5.698v.015a.135.135 0 0 1-.008.038c-.01.02-.03.024-.05.03a1.83 1.83 0 0 0-.548.273.154.154 0 0 0-.02.017c-.011.012-.022.023-.04.025a.114.114 0 0 1-.043-.007l-5.818-2.472-.011-.005c-.037-.015-.081-.033-.081-.071a2.198 2.198 0 0 0-.31-.915c-.028-.046-.059-.094-.035-.141l4.066-6.303zm-3.932 8.606l5.454 2.31c.03.014.063.027.076.058a.106.106 0 0 1 0 .057c-.016.08-.03.171-.03.263v.153c0 .038-.039.054-.075.069l-.011.004c-.864.369-12.13 5.173-12.147 5.173-.017 0-.035 0-.052-.017-.03-.03 0-.072.027-.11a.76.76 0 0 0 .014-.02l4.482-6.94.008-.012c.026-.042.056-.089.104-.089l.045.007c.102.014.192.027.283.027.68 0 1.31-.331 1.69-.897a.16.16 0 0 1 .034-.04c.027-.02.067-.01.098.004zm-6.246 9.185l12.28-5.237s.018 0 .035.017c.067.067.124.112.179.154l.027.017c.025.014.05.03.052.056 0 .01 0 .016-.002.025L25.756 23.7l-.004.026c-.007.05-.014.107-.061.107a1.729 1.729 0 0 0-1.373.847l-.005.008c-.014.023-.027.045-.05.057-.021.01-.048.006-.07.001l-9.793-2.02c-.01-.002-.152-.519-.163-.52z"
/>
</svg>
);
// Add new interface for site actions
interface SiteAction {
name: string;
icon: React.ComponentType<any>;
action: (siteId: string) => Promise<void>;
requiresConfirmation?: boolean;
variant?: 'default' | 'destructive' | 'outline';
}
export default function NetlifyConnection() {
console.log('NetlifyConnection component mounted');
const connection = useStore(netlifyConnection);
const [tokenInput, setTokenInput] = useState('');
const [fetchingStats, setFetchingStats] = useState(false);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
console.log('NetlifyConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
// Add site actions
const siteActions: SiteAction[] = [
{
name: 'Clear Cache',
icon: ArrowPathIcon,
action: async (siteId: string) => {
try {
// Try to get site details first to check for build hooks
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!siteResponse.ok) {
const errorText = await siteResponse.text();
if (siteResponse.status === 404) {
toast.error('Site not found. This may be a free account limitation.');
return;
}
throw new Error(`Failed to get site details: ${errorText}`);
}
const siteData = (await siteResponse.json()) as any;
// Check if this looks like a free account (limited features)
const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
// If site has build hooks, try triggering a build instead
if (siteData.build_settings && siteData.build_settings.repo_url) {
// Try to trigger a build by making a POST to the site's build endpoint
const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
clear_cache: true,
}),
});
if (buildResponse.ok) {
toast.success('Build triggered with cache clear');
return;
} else if (buildResponse.status === 422) {
// Often indicates free account limitation
toast.warning('Build trigger failed. This feature may not be available on free accounts.');
return;
}
}
// Fallback: Try the standard cache purge endpoint
const cacheResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/purge_cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!cacheResponse.ok) {
if (cacheResponse.status === 404) {
if (isFreeAccount) {
toast.warning('Cache purge not available on free accounts. Try triggering a build instead.');
} else {
toast.error('Cache purge endpoint not found. This feature may not be available.');
}
return;
}
const errorText = await cacheResponse.text();
throw new Error(`Cache purge failed: ${errorText}`);
}
toast.success('Site cache cleared successfully');
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to clear site cache: ${error}`);
}
},
},
{
name: 'Manage Environment',
icon: CogIcon,
action: async (siteId: string) => {
try {
// Get site info first to check account type
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!siteResponse.ok) {
throw new Error('Failed to get site details');
}
const siteData = (await siteResponse.json()) as any;
const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
// Get environment variables
const envResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/env`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (envResponse.ok) {
const envVars = (await envResponse.json()) as any[];
toast.success(`Environment variables loaded: ${envVars.length} variables`);
} else if (envResponse.status === 404) {
if (isFreeAccount) {
toast.info('Environment variables management is limited on free accounts');
} else {
toast.info('Site has no environment variables configured');
}
} else {
const errorText = await envResponse.text();
toast.error(`Failed to load environment variables: ${errorText}`);
}
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to load environment variables: ${error}`);
}
},
},
{
name: 'Trigger Build',
icon: RocketLaunchIcon,
action: async (siteId: string) => {
try {
const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!buildResponse.ok) {
throw new Error('Failed to trigger build');
}
const buildData = (await buildResponse.json()) as any;
toast.success(`Build triggered successfully! ID: ${buildData.id}`);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to trigger build: ${error}`);
}
},
},
{
name: 'View Functions',
icon: CodeBracketIcon,
action: async (siteId: string) => {
try {
// Get site info first to check account type
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!siteResponse.ok) {
throw new Error('Failed to get site details');
}
const siteData = (await siteResponse.json()) as any;
const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
const functionsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/functions`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (functionsResponse.ok) {
const functions = (await functionsResponse.json()) as any[];
toast.success(`Site has ${functions.length} serverless functions`);
} else if (functionsResponse.status === 404) {
if (isFreeAccount) {
toast.info('Functions may be limited or unavailable on free accounts');
} else {
toast.info('Site has no serverless functions');
}
} else {
const errorText = await functionsResponse.text();
toast.error(`Failed to load functions: ${errorText}`);
}
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to load functions: ${error}`);
}
},
},
{
name: 'Site Analytics',
icon: ChartBarIcon,
action: async (siteId: string) => {
try {
// Get site info first to check account type
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!siteResponse.ok) {
throw new Error('Failed to get site details');
}
const siteData = (await siteResponse.json()) as any;
const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
// Get site traffic data (if available)
const analyticsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/traffic`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (analyticsResponse.ok) {
await analyticsResponse.json(); // Analytics data received
toast.success('Site analytics loaded successfully');
} else if (analyticsResponse.status === 404) {
if (isFreeAccount) {
toast.info('Analytics not available on free accounts. Showing basic site info instead.');
}
// Fallback to basic site info
toast.info(`Site: ${siteData.name} - Status: ${siteData.state || 'Unknown'}`);
} else {
const errorText = await analyticsResponse.text();
if (isFreeAccount) {
toast.info(
'Analytics unavailable on free accounts. Site info: ' +
`${siteData.name} (${siteData.state || 'Unknown'})`,
);
} else {
toast.error(`Failed to load analytics: ${errorText}`);
}
}
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to load site analytics: ${error}`);
}
},
},
{
name: 'Delete Site',
icon: TrashIcon,
action: async (siteId: string) => {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to delete site');
}
toast.success('Site deleted successfully');
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete site: ${error}`);
}
},
requiresConfirmation: true,
variant: 'destructive',
},
];
// Add deploy management functions
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
try {
setIsActionLoading(true);
const endpoint =
action === 'publish'
? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
: `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to ${action} deploy`);
}
toast.success(`Deploy ${action}ed successfully`);
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to ${action} deploy: ${error}`);
} finally {
setIsActionLoading(false);
}
};
useEffect(() => {
console.log('Netlify: Running initialization useEffect');
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
useEffect(() => {
// Check if we have a connection with a token but no stats
if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
fetchNetlifyStats(connection.token);
}
// Update local state from connection
if (connection.stats) {
setSites(connection.stats.sites || []);
setDeploys(connection.stats.deploys || []);
setBuilds(connection.stats.builds || []);
setDeploymentCount(connection.stats.deploys?.length || 0);
setLastUpdated(connection.stats.lastDeployTime || '');
}
}, [connection]);
const handleConnect = async () => {
if (!tokenInput) {
toast.error('Please enter a Netlify API token');
return;
}
setIsConnecting(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${tokenInput}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const userData = (await response.json()) as NetlifyUser;
// Update the connection store
updateNetlifyConnection({
user: userData,
token: tokenInput,
});
toast.success('Connected to Netlify successfully');
// Fetch stats after successful connection
fetchNetlifyStats(tokenInput);
} catch (error) {
console.error('Error connecting to Netlify:', error);
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsConnecting(false);
setTokenInput('');
}
};
const handleDisconnect = () => {
// Clear from localStorage
localStorage.removeItem('netlify_connection');
// Remove cookies
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Update the store
updateNetlifyConnection({ user: null, token: '' });
toast.success('Disconnected from Netlify');
};
const fetchNetlifyStats = async (token: string) => {
setFetchingStats(true);
try {
// Fetch sites
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!sitesResponse.ok) {
throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
}
const sitesData = (await sitesResponse.json()) as NetlifySite[];
setSites(sitesData);
// Fetch recent deploys for the first site (if any)
let deploysData: NetlifyDeploy[] = [];
let buildsData: NetlifyBuild[] = [];
let lastDeployTime = '';
if (sitesData && sitesData.length > 0) {
const firstSite = sitesData[0];
// Fetch deploys
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (deploysResponse.ok) {
deploysData = (await deploysResponse.json()) as NetlifyDeploy[];
setDeploys(deploysData);
setDeploymentCount(deploysData.length);
// Get the latest deploy time
if (deploysData.length > 0) {
lastDeployTime = deploysData[0].created_at;
setLastUpdated(lastDeployTime);
// Fetch builds for the site
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (buildsResponse.ok) {
buildsData = (await buildsResponse.json()) as NetlifyBuild[];
setBuilds(buildsData);
}
}
}
}
// Update the stats in the store
updateNetlifyConnection({
stats: {
sites: sitesData,
deploys: deploysData,
builds: buildsData,
lastDeployTime,
totalSites: sitesData.length,
},
});
toast.success('Netlify stats updated');
} catch (error) {
console.error('Error fetching Netlify stats:', error);
toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setFetchingStats(false);
}
};
const renderStats = () => {
if (!connection.user || !connection.stats) {
return null;
}
return (
<div className="mt-6">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Stats
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{connection.stats.totalSites} Sites</span>
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<RocketLaunchIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{deploymentCount} Deployments</span>
</Badge>
{lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>Updated {formatDistanceToNow(new Date(lastUpdated))} ago</span>
</Badge>
)}
</div>
{sites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Your Sites
</h4>
<Button
variant="outline"
size="sm"
onClick={() => fetchNetlifyStats(connection.token)}
disabled={fetchingStats}
className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
>
<ArrowPathIcon
className={classNames(
'h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent',
{ 'animate-spin': fetchingStats },
)}
/>
{fetchingStats ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
<div className="space-y-3">
{sites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-bolt-elements-item-contentAccent bg-bolt-elements-item-backgroundActive/10'
: 'border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70',
)}
onClick={() => {
setActiveSiteIndex(index);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CloudIcon className="h-5 w-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{site.name}
</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
{site.published_deploy?.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : (
<XCircleIcon className="h-4 w-4 text-red-500" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{site.published_deploy?.state || 'Unknown'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<a
href={site.ssl_url || site.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{site.ssl_url || site.url}
</span>
</a>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-bolt-elements-borderColor">
<div className="flex items-center gap-2">
{siteActions.map((action) => (
<Button
key={action.name}
variant={action.variant || 'outline'}
size="sm"
onClick={async (e) => {
e.stopPropagation();
if (action.requiresConfirmation) {
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
return;
}
}
setIsActionLoading(true);
await action.action(site.id);
setIsActionLoading(false);
}}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<action.icon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
{action.name}
</Button>
))}
</div>
</div>
{site.published_deploy && (
<div className="mt-3 text-sm">
<div className="flex items-center gap-1">
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
</span>
</div>
{site.published_deploy.branch && (
<div className="flex items-center gap-1 mt-1">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {site.published_deploy.branch}
</span>
</div>
)}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
{activeSiteIndex !== -1 && deploys.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Deployments
</h4>
</div>
<div className="space-y-2">
{deploys.map((deploy) => (
<div
key={deploy.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
deploy.state === 'ready'
? 'default'
: deploy.state === 'error'
? 'destructive'
: 'outline'
}
className="flex items-center gap-1"
>
{deploy.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : deploy.state === 'error' ? (
<XCircleIcon className="h-4 w-4 text-red-500" />
) : (
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{deploy.state}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(deploy.created_at))} ago
</span>
</div>
{deploy.branch && (
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-1">
<CodeBracketIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {deploy.branch}
</span>
</div>
)}
{deploy.deploy_url && (
<div className="mt-2 text-xs">
<a
href={deploy.deploy_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
</a>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Publish
</Button>
{deploy.state === 'ready' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockClosedIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Lock
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockOpenIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Unlock
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
{activeSiteIndex !== -1 && builds.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Builds
</h4>
</div>
<div className="space-y-2">
{builds.map((build) => (
<div
key={build.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
}
className="flex items-center gap-1"
>
{build.done && !build.error ? (
<CheckCircleIcon className="h-4 w-4" />
) : build.error ? (
<XCircleIcon className="h-4 w-4" />
) : (
<CodeBracketIcon className="h-4 w-4" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(build.created_at))} ago
</span>
</div>
{build.error && (
<div className="mt-2 text-xs text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive flex items-center gap-1">
<XCircleIcon className="h-3 w-3 text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive" />
Error: {build.error}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
return (
<div className="space-y-6 bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-[#00AD9F]">
<NetlifyLogo />
</div>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Netlify Connection</h2>
</div>
</div>
{!connection.user ? (
<div className="mt-4">
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
API Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual Netlify auto-connect test');
await initializeNetlifyConnection();
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (
<div className="flex flex-col w-full gap-4 mt-4">
<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>
{renderStats()}
</div>
)}
</div>
</div>
);
}

View File

@@ -1 +0,0 @@
export { default as NetlifyConnection } from './NetlifyConnection';

View File

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

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
import React, { Component } from 'react';
import type { ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
import { classNames } from '~/utils/classNames';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Local Providers Error Boundary caught an error:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className={classNames('p-6 rounded-lg border border-red-500/20', 'bg-red-500/5 text-center')}>
<AlertCircle className="w-12 h-12 mx-auto text-red-500 mb-4" />
<h3 className="text-lg font-medium text-red-500 mb-2">Something went wrong</h3>
<p className="text-sm text-red-400 mb-4">There was an error loading the local providers section.</p>
<button
onClick={() => this.setState({ hasError: false, error: undefined })}
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'bg-red-500/10 text-red-500',
'hover:bg-red-500/20',
'transition-colors duration-200',
)}
>
Try Again
</button>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-red-400 hover:text-red-300">Error Details</summary>
<pre className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-300 overflow-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react';
import { classNames } from '~/utils/classNames';
interface HealthStatusBadgeProps {
status: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
responseTime?: number;
className?: string;
}
function HealthStatusBadge({ status, responseTime, className }: HealthStatusBadgeProps) {
const getStatusConfig = () => {
switch (status) {
case 'healthy':
return {
color: 'text-green-500',
bgColor: 'bg-green-500/10 border-green-500/20',
Icon: CheckCircle,
label: 'Healthy',
};
case 'unhealthy':
return {
color: 'text-red-500',
bgColor: 'bg-red-500/10 border-red-500/20',
Icon: XCircle,
label: 'Unhealthy',
};
case 'checking':
return {
color: 'text-blue-500',
bgColor: 'bg-blue-500/10 border-blue-500/20',
Icon: Loader2,
label: 'Checking',
};
default:
return {
color: 'text-bolt-elements-textTertiary',
bgColor: 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor',
Icon: AlertCircle,
label: 'Unknown',
};
}
};
const config = getStatusConfig();
const Icon = config.Icon;
return (
<div
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
config.bgColor,
config.color,
className,
)}
>
<Icon className={classNames('w-3 h-3', { 'animate-spin': status === 'checking' })} />
<span>{config.label}</span>
{responseTime !== undefined && status === 'healthy' && <span className="opacity-75">({responseTime}ms)</span>}
</div>
);
}
export default HealthStatusBadge;

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
interface LoadingSkeletonProps {
className?: string;
lines?: number;
height?: string;
}
export function LoadingSkeleton({ className, lines = 1, height = 'h-4' }: LoadingSkeletonProps) {
return (
<div className={classNames('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={classNames('bg-bolt-elements-background-depth-3 rounded', height, 'animate-pulse')}
style={{ animationDelay: `${i * 0.1}s` }}
/>
))}
</div>
);
}
interface ModelCardSkeletonProps {
className?: string;
}
export function ModelCardSkeleton({ className }: ModelCardSkeletonProps) {
return (
<div
className={classNames(
'border rounded-lg p-4',
'bg-bolt-elements-background-depth-2',
'border-bolt-elements-borderColor',
className,
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className="w-3 h-3 rounded-full bg-bolt-elements-textTertiary animate-pulse" />
<div className="space-y-2 flex-1">
<LoadingSkeleton height="h-5" lines={1} className="w-3/4" />
<LoadingSkeleton height="h-3" lines={1} className="w-1/2" />
</div>
</div>
<div className="w-4 h-4 bg-bolt-elements-textTertiary rounded animate-pulse" />
</div>
</div>
);
}
interface ProviderCardSkeletonProps {
className?: string;
}
export function ProviderCardSkeleton({ className }: ProviderCardSkeletonProps) {
return (
<div className={classNames('bg-bolt-elements-background-depth-2 rounded-xl p-5', className)}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 animate-pulse" />
<div className="space-y-3 flex-1">
<div className="space-y-2">
<LoadingSkeleton height="h-5" lines={1} className="w-1/3" />
<LoadingSkeleton height="h-4" lines={1} className="w-2/3" />
</div>
<div className="space-y-2">
<LoadingSkeleton height="h-3" lines={1} className="w-1/4" />
<LoadingSkeleton height="h-8" lines={1} className="w-full" />
</div>
</div>
</div>
<div className="w-10 h-6 bg-bolt-elements-background-depth-3 rounded-full animate-pulse" />
</div>
</div>
);
}
interface ModelManagerSkeletonProps {
className?: string;
cardCount?: number;
}
export function ModelManagerSkeleton({ className, cardCount = 3 }: ModelManagerSkeletonProps) {
return (
<div className={classNames('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<LoadingSkeleton height="h-6" lines={1} className="w-48" />
<LoadingSkeleton height="h-4" lines={1} className="w-64" />
</div>
<div className="flex items-center gap-2">
<div className="w-24 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
<div className="w-16 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
</div>
</div>
{/* Model Cards */}
<div className="space-y-4">
{Array.from({ length: cardCount }).map((_, i) => (
<ModelCardSkeleton key={i} />
))}
</div>
</div>
);
}

View File

@@ -1,556 +0,0 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { Switch } from '~/components/ui/Switch';
import { Card, CardContent, CardHeader } from '~/components/ui/Card';
import { Button } from '~/components/ui/Button';
import { useSettings } from '~/lib/hooks/useSettings';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { useToast } from '~/components/ui/use-toast';
import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
import ErrorBoundary from './ErrorBoundary';
import { ModelCardSkeleton } from './LoadingSkeleton';
import SetupGuide from './SetupGuide';
import StatusDashboard from './StatusDashboard';
import ProviderCard from './ProviderCard';
import ModelCard from './ModelCard';
import { OLLAMA_API_URL } from './types';
import type { OllamaModel, LMStudioModel } from './types';
import { Cpu, Server, BookOpen, Activity, PackageOpen, Monitor, Loader2, RotateCw, ExternalLink } from 'lucide-react';
// Type definitions
type ViewMode = 'dashboard' | 'guide' | 'status';
export default function LocalProvidersTab() {
const { providers, updateProviderSettings } = useSettings();
const [viewMode, setViewMode] = useState<ViewMode>('dashboard');
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [lmStudioModels, setLMStudioModels] = useState<LMStudioModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [isLoadingLMStudioModels, setIsLoadingLMStudioModels] = useState(false);
const { toast } = useToast();
const { startMonitoring, stopMonitoring } = useLocalModelHealth();
// Memoized filtered providers to prevent unnecessary re-renders
const filteredProviders = useMemo(() => {
return Object.entries(providers || {})
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
.map(([key, value]) => {
const provider = value as IProviderConfig;
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
// Set default base URLs for local providers
let defaultBaseUrl = provider.settings.baseUrl || envUrl;
if (!defaultBaseUrl) {
if (key === 'Ollama') {
defaultBaseUrl = 'http://127.0.0.1:11434';
} else if (key === 'LMStudio') {
defaultBaseUrl = 'http://127.0.0.1:1234';
}
}
return {
name: key,
settings: {
...provider.settings,
baseUrl: defaultBaseUrl,
},
staticModels: provider.staticModels || [],
getDynamicModels: provider.getDynamicModels,
getApiKeyLink: provider.getApiKeyLink,
labelForGetApiKey: provider.labelForGetApiKey,
icon: provider.icon,
} as IProviderConfig;
})
.sort((a, b) => {
// Custom sort: Ollama first, then LMStudio, then OpenAILike
const order = { Ollama: 0, LMStudio: 1, OpenAILike: 2 };
return (order[a.name as keyof typeof order] || 3) - (order[b.name as keyof typeof order] || 3);
});
}, [providers]);
const categoryEnabled = useMemo(() => {
return filteredProviders.length > 0 && filteredProviders.every((p) => p.settings.enabled);
}, [filteredProviders]);
// Start/stop health monitoring for enabled providers
useEffect(() => {
filteredProviders.forEach((provider) => {
const baseUrl = provider.settings.baseUrl;
if (provider.settings.enabled && baseUrl) {
console.log(`[LocalProvidersTab] Starting monitoring for ${provider.name} at ${baseUrl}`);
startMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl);
} else if (!provider.settings.enabled && baseUrl) {
console.log(`[LocalProvidersTab] Stopping monitoring for ${provider.name} at ${baseUrl}`);
stopMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl);
}
});
}, [filteredProviders, startMonitoring, stopMonitoring]);
// Fetch Ollama models when enabled
useEffect(() => {
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
if (ollamaProvider?.settings.enabled) {
fetchOllamaModels();
}
}, [filteredProviders]);
// Fetch LM Studio models when enabled
useEffect(() => {
const lmStudioProvider = filteredProviders.find((p) => p.name === 'LMStudio');
if (lmStudioProvider?.settings.enabled && lmStudioProvider.settings.baseUrl) {
fetchLMStudioModels(lmStudioProvider.settings.baseUrl);
}
}, [filteredProviders]);
const fetchOllamaModels = async () => {
try {
setIsLoadingModels(true);
const response = await fetch(`${OLLAMA_API_URL}/api/tags`);
if (!response.ok) {
throw new Error('Failed to fetch models');
}
const data = (await response.json()) as { models: OllamaModel[] };
setOllamaModels(
data.models.map((model) => ({
...model,
status: 'idle' as const,
})),
);
} catch {
console.error('Error fetching Ollama models');
} finally {
setIsLoadingModels(false);
}
};
const fetchLMStudioModels = async (baseUrl: string) => {
try {
setIsLoadingLMStudioModels(true);
const response = await fetch(`${baseUrl}/v1/models`);
if (!response.ok) {
throw new Error('Failed to fetch LM Studio models');
}
const data = (await response.json()) as { data: LMStudioModel[] };
setLMStudioModels(data.data || []);
} catch {
console.error('Error fetching LM Studio models');
setLMStudioModels([]);
} finally {
setIsLoadingLMStudioModels(false);
}
};
const handleToggleCategory = useCallback(
async (enabled: boolean) => {
filteredProviders.forEach((provider) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
},
[filteredProviders, updateProviderSettings, toast],
);
const handleToggleProvider = useCallback(
(provider: IProviderConfig, enabled: boolean) => {
updateProviderSettings(provider.name, {
...provider.settings,
enabled,
});
logStore.logProvider(`Provider ${provider.name} ${enabled ? 'enabled' : 'disabled'}`, {
provider: provider.name,
});
toast(`${provider.name} ${enabled ? 'enabled' : 'disabled'}`);
},
[updateProviderSettings, toast],
);
const handleUpdateBaseUrl = useCallback(
(provider: IProviderConfig, newBaseUrl: string) => {
updateProviderSettings(provider.name, {
...provider.settings,
baseUrl: newBaseUrl,
});
toast(`${provider.name} base URL updated`);
},
[updateProviderSettings, toast],
);
const handleUpdateOllamaModel = async (modelName: string) => {
try {
setOllamaModels((prev) => prev.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to update ${modelName}`);
}
// Handle streaming response
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response reader available');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.status && data.completed && data.total) {
setOllamaModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: data.completed,
total: data.total,
status: data.status,
},
}
: m,
),
);
}
} catch {
// Ignore parsing errors
}
}
}
setOllamaModels((prev) =>
prev.map((m) => (m.name === modelName ? { ...m, status: 'updated', progress: undefined } : m)),
);
toast(`Successfully updated ${modelName}`);
} catch {
setOllamaModels((prev) =>
prev.map((m) => (m.name === modelName ? { ...m, status: 'error', progress: undefined } : m)),
);
toast(`Failed to update ${modelName}`, { type: 'error' });
}
};
const handleDeleteOllamaModel = async (modelName: string) => {
if (!window.confirm(`Are you sure you want to delete ${modelName}?`)) {
return;
}
try {
const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to delete ${modelName}`);
}
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
toast(`Deleted ${modelName}`);
} catch {
toast(`Failed to delete ${modelName}`, { type: 'error' });
}
};
// Render different views based on viewMode
if (viewMode === 'guide') {
return (
<ErrorBoundary>
<SetupGuide onBack={() => setViewMode('dashboard')} />
</ErrorBoundary>
);
}
if (viewMode === 'status') {
return (
<ErrorBoundary>
<StatusDashboard onBack={() => setViewMode('dashboard')} />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/20 flex items-center justify-center ring-1 ring-purple-500/30">
<Cpu className="w-6 h-6 text-purple-500" />
</div>
<div>
<h2 className="text-2xl font-semibold text-bolt-elements-textPrimary">Local AI Providers</h2>
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI models</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textSecondary">Enable All</span>
<Switch
checked={categoryEnabled}
onCheckedChange={handleToggleCategory}
aria-label="Toggle all local providers"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setViewMode('guide')}
className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-purple-500/30 transition-all duration-200 gap-2"
>
<BookOpen className="w-4 h-4" />
Setup Guide
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setViewMode('status')}
className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-purple-500/30 transition-all duration-200 gap-2"
>
<Activity className="w-4 h-4" />
Status
</Button>
</div>
</div>
</div>
{/* Provider Cards */}
<div className="space-y-6">
{filteredProviders.map((provider) => (
<div key={provider.name} className="space-y-4">
<ProviderCard
provider={provider}
onToggle={(enabled) => handleToggleProvider(provider, enabled)}
onUpdateBaseUrl={(url) => handleUpdateBaseUrl(provider, url)}
isEditing={editingProvider === provider.name}
onStartEditing={() => setEditingProvider(provider.name)}
onStopEditing={() => setEditingProvider(null)}
/>
{/* Ollama Models Section */}
{provider.name === 'Ollama' && provider.settings.enabled && (
<Card className="mt-4 bg-bolt-elements-background-depth-2">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PackageOpen className="w-5 h-5 text-purple-500" />
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Installed Models</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchOllamaModels}
disabled={isLoadingModels}
className="bg-transparent hover:bg-bolt-elements-background-depth-2"
>
{isLoadingModels ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RotateCw className="w-4 h-4 mr-2" />
)}
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoadingModels ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<ModelCardSkeleton key={i} />
))}
</div>
) : ollamaModels.length === 0 ? (
<div className="text-center py-8">
<PackageOpen className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Models Installed</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Visit{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline inline-flex items-center gap-1"
>
ollama.com/library
<ExternalLink className="w-3 h-3" />
</a>{' '}
to browse available models
</p>
<Button
variant="outline"
size="sm"
className="bg-gradient-to-r from-purple-500/8 to-purple-600/8 hover:from-purple-500/15 hover:to-purple-600/15 border-purple-500/25 hover:border-purple-500/40 transition-all duration-300 gap-2 group shadow-sm hover:shadow-md font-medium"
_asChild
>
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
<span className="flex-1 text-center font-medium">Browse Models</span>
</a>
</Button>
</div>
) : (
<div className="grid gap-4">
{ollamaModels.map((model) => (
<ModelCard
key={model.name}
model={model}
onUpdate={() => handleUpdateOllamaModel(model.name)}
onDelete={() => handleDeleteOllamaModel(model.name)}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* LM Studio Models Section */}
{provider.name === 'LMStudio' && provider.settings.enabled && (
<Card className="mt-4 bg-bolt-elements-background-depth-2">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-blue-500" />
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Available Models</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fetchLMStudioModels(provider.settings.baseUrl!)}
disabled={isLoadingLMStudioModels}
className="bg-transparent hover:bg-bolt-elements-background-depth-2"
>
{isLoadingLMStudioModels ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RotateCw className="w-4 h-4 mr-2" />
)}
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoadingLMStudioModels ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<ModelCardSkeleton key={i} />
))}
</div>
) : lmStudioModels.length === 0 ? (
<div className="text-center py-8">
<Monitor className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Models Available</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Make sure LM Studio is running with the local server started and CORS enabled.
</p>
<Button
variant="outline"
size="sm"
className="bg-gradient-to-r from-blue-500/8 to-blue-600/8 hover:from-blue-500/15 hover:to-blue-600/15 border-blue-500/25 hover:border-blue-500/40 transition-all duration-300 gap-2 group shadow-sm hover:shadow-md font-medium"
_asChild
>
<a
href="https://lmstudio.ai/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
<span className="flex-1 text-center font-medium">Get LM Studio</span>
</a>
</Button>
</div>
) : (
<div className="grid gap-4">
{lmStudioModels.map((model) => (
<Card key={model.id} className="bg-bolt-elements-background-depth-3">
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
{model.id}
</h4>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-500">
Available
</span>
</div>
<div className="flex items-center gap-4 text-xs text-bolt-elements-textSecondary">
<div className="flex items-center gap-1">
<Server className="w-3 h-3" />
<span>{model.object}</span>
</div>
<div className="flex items-center gap-1">
<Activity className="w-3 h-3" />
<span>Owned by: {model.owned_by}</span>
</div>
{model.created && (
<div className="flex items-center gap-1">
<Activity className="w-3 h-3" />
<span>Created: {new Date(model.created * 1000).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
))}
</div>
{filteredProviders.length === 0 && (
<Card className="bg-bolt-elements-background-depth-2">
<CardContent className="p-8 text-center">
<Server className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Local Providers Available</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Local providers will appear here when they're configured in the system.
</p>
</CardContent>
</Card>
)}
</div>
</ErrorBoundary>
);
}

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { Card, CardContent } from '~/components/ui/Card';
import { Progress } from '~/components/ui/Progress';
import { RotateCw, Trash2, Code, Database, Package, Loader2 } from 'lucide-react';
import { classNames } from '~/utils/classNames';
import type { OllamaModel } from './types';
// Model Card Component
interface ModelCardProps {
model: OllamaModel;
onUpdate: () => void;
onDelete: () => void;
}
function ModelCard({ model, onUpdate, onDelete }: ModelCardProps) {
return (
<Card className="bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 transition-all duration-200 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/20">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary font-mono">{model.name}</h4>
{model.status && model.status !== 'idle' && (
<span
className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', {
'bg-yellow-500/10 text-yellow-500': model.status === 'updating',
'bg-green-500/10 text-green-500': model.status === 'updated',
'bg-red-500/10 text-red-500': model.status === 'error',
})}
>
{model.status === 'updating' && 'Updating'}
{model.status === 'updated' && 'Updated'}
{model.status === 'error' && 'Error'}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-bolt-elements-textSecondary">
<div className="flex items-center gap-1">
<Code className="w-3 h-3" />
<span>{model.digest.substring(0, 8)}</span>
</div>
{model.details && (
<>
<div className="flex items-center gap-1">
<Database className="w-3 h-3" />
<span>{model.details.parameter_size}</span>
</div>
<div className="flex items-center gap-1">
<Package className="w-3 h-3" />
<span>{model.details.quantization_level}</span>
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onUpdate}
disabled={model.status === 'updating'}
className={classNames(
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20 hover:shadow-sm',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-purple-500/10',
)}
>
{model.status === 'updating' ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Updating
</>
) : (
<>
<RotateCw className="w-3 h-3" />
Update
</>
)}
</button>
<button
onClick={onDelete}
disabled={model.status === 'updating'}
className={classNames(
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
'bg-red-500/10 text-red-500 hover:bg-red-500/20 hover:shadow-sm',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-500/10',
)}
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</div>
</div>
{model.progress && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-xs text-bolt-elements-textSecondary">
<span>{model.progress.status}</span>
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
</div>
<Progress value={Math.round((model.progress.current / model.progress.total) * 100)} className="h-1" />
</div>
)}
</CardContent>
</Card>
);
}
export default ModelCard;

View File

@@ -1,120 +0,0 @@
import React from 'react';
import { Switch } from '~/components/ui/Switch';
import { Card, CardContent } from '~/components/ui/Card';
import { Link, Server, Monitor, Globe } from 'lucide-react';
import { classNames } from '~/utils/classNames';
import type { IProviderConfig } from '~/types/model';
import { PROVIDER_DESCRIPTIONS } from './types';
// Provider Card Component
interface ProviderCardProps {
provider: IProviderConfig;
onToggle: (enabled: boolean) => void;
onUpdateBaseUrl: (url: string) => void;
isEditing: boolean;
onStartEditing: () => void;
onStopEditing: () => void;
}
function ProviderCard({
provider,
onToggle,
onUpdateBaseUrl,
isEditing,
onStartEditing,
onStopEditing,
}: ProviderCardProps) {
const getIcon = (providerName: string) => {
switch (providerName) {
case 'Ollama':
return Server;
case 'LMStudio':
return Monitor;
case 'OpenAILike':
return Globe;
default:
return Server;
}
};
const Icon = getIcon(provider.name);
return (
<Card className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-all duration-300 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/30">
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div
className={classNames(
'w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300',
provider.settings.enabled
? 'bg-gradient-to-br from-purple-500/20 to-purple-600/20 ring-1 ring-purple-500/30'
: 'bg-bolt-elements-background-depth-3',
)}
>
<Icon
className={classNames(
'w-6 h-6 transition-all duration-300',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textTertiary',
)}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<span className="px-2 py-1 text-xs rounded-full bg-green-500/10 text-green-500 font-medium">Local</span>
</div>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS]}
</p>
{provider.settings.enabled && (
<div className="space-y-2">
<label className="text-sm font-medium text-bolt-elements-textPrimary">API Endpoint</label>
{isEditing ? (
<input
type="text"
defaultValue={provider.settings.baseUrl}
placeholder={`Enter ${provider.name} base URL`}
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-4 border border-purple-500/30 text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 shadow-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
onUpdateBaseUrl(e.currentTarget.value);
onStopEditing();
} else if (e.key === 'Escape') {
onStopEditing();
}
}}
onBlur={(e) => {
onUpdateBaseUrl(e.target.value);
onStopEditing();
}}
autoFocus
/>
) : (
<button
onClick={onStartEditing}
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4 hover:shadow-sm transition-all duration-200 text-left group"
>
<div className="flex items-center gap-3 text-bolt-elements-textSecondary group-hover:text-bolt-elements-textPrimary">
<Link className="w-4 h-4 group-hover:text-purple-500 transition-colors" />
<span className="font-mono">{provider.settings.baseUrl || 'Click to set base URL'}</span>
</div>
</button>
)}
</div>
)}
</div>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={onToggle}
aria-label={`Toggle ${provider.name} provider`}
/>
</div>
</CardContent>
</Card>
);
}
export default ProviderCard;

View File

@@ -1,671 +0,0 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import { Card, CardContent, CardHeader } from '~/components/ui/Card';
import {
Cpu,
Server,
Settings,
ExternalLink,
Package,
Code,
Database,
CheckCircle,
AlertCircle,
Activity,
Cable,
ArrowLeft,
Download,
Shield,
Globe,
Terminal,
Monitor,
Wifi,
} from 'lucide-react';
// Setup Guide Component
function SetupGuide({ onBack }: { onBack: () => void }) {
return (
<div className="space-y-6">
{/* Header with Back Button */}
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
aria-label="Back to Dashboard"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Local Provider Setup Guide</h2>
<p className="text-sm text-bolt-elements-textSecondary">
Complete setup instructions for running AI models locally
</p>
</div>
</div>
{/* Hardware Requirements Overview */}
<Card className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Shield className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">System Requirements</h3>
<p className="text-sm text-bolt-elements-textSecondary">Recommended hardware for optimal performance</p>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4 text-sm">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-green-500" />
<span className="font-medium text-bolt-elements-textPrimary">CPU</span>
</div>
<p className="text-bolt-elements-textSecondary">8+ cores, modern architecture</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-blue-500" />
<span className="font-medium text-bolt-elements-textPrimary">RAM</span>
</div>
<p className="text-bolt-elements-textSecondary">16GB minimum, 32GB+ recommended</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-purple-500" />
<span className="font-medium text-bolt-elements-textPrimary">GPU</span>
</div>
<p className="text-bolt-elements-textSecondary">NVIDIA RTX 30xx+ or AMD RX 6000+</p>
</div>
</div>
</CardContent>
</Card>
{/* Ollama Setup Section */}
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
<CardHeader className="pb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/20 flex items-center justify-center ring-1 ring-purple-500/30">
<Server className="w-6 h-6 text-purple-500" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Ollama Setup</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Most popular choice for running open-source models locally with desktop app
</p>
</div>
<span className="px-3 py-1 bg-purple-500/10 text-purple-500 text-xs font-medium rounded-full">
Recommended
</span>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Installation Options */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Download className="w-4 h-4" />
1. Choose Installation Method
</h4>
{/* Desktop App - New and Recommended */}
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
<div className="flex items-center gap-2 mb-3">
<Monitor className="w-5 h-5 text-green-500" />
<h5 className="font-medium text-green-500">🆕 Desktop App (Recommended)</h5>
</div>
<p className="text-sm text-bolt-elements-textSecondary mb-3">
New user-friendly desktop application with built-in model management and web interface.
</p>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
<strong className="text-bolt-elements-textPrimary">macOS</strong>
</div>
<Button
variant="outline"
size="sm"
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
_asChild
>
<a
href="https://ollama.com/download/mac"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
<span className="flex-1 text-center font-medium">Download Desktop App</span>
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
</a>
</Button>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
<strong className="text-bolt-elements-textPrimary">Windows</strong>
</div>
<Button
variant="outline"
size="sm"
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
_asChild
>
<a
href="https://ollama.com/download/windows"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
<span className="flex-1 text-center font-medium">Download Desktop App</span>
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
</a>
</Button>
</div>
</div>
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-1">
<Globe className="w-4 h-4 text-blue-500" />
<span className="font-medium text-blue-500 text-sm">Built-in Web Interface</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">
Desktop app includes a web interface at{' '}
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">http://localhost:11434</code>
</p>
</div>
</div>
{/* CLI Installation */}
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-3">
<Terminal className="w-5 h-5 text-bolt-elements-textPrimary" />
<h5 className="font-medium text-bolt-elements-textPrimary">Command Line (Advanced)</h5>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
<strong className="text-bolt-elements-textPrimary">Windows</strong>
</div>
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
winget install Ollama.Ollama
</div>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
<strong className="text-bolt-elements-textPrimary">macOS</strong>
</div>
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
brew install ollama
</div>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-4 h-4 text-bolt-elements-textPrimary" />
<strong className="text-bolt-elements-textPrimary">Linux</strong>
</div>
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
curl -fsSL https://ollama.com/install.sh | sh
</div>
</div>
</div>
</div>
</div>
{/* Latest Model Recommendations */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Package className="w-4 h-4" />
2. Download Latest Models
</h4>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
<Code className="w-4 h-4 text-green-500" />
Code & Development
</h5>
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
<div># Latest Llama 3.2 for coding</div>
<div>ollama pull llama3.2:3b</div>
<div>ollama pull codellama:13b</div>
<div>ollama pull deepseek-coder-v2</div>
<div>ollama pull qwen2.5-coder:7b</div>
</div>
</div>
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
<Terminal className="w-4 h-4 text-blue-500" />
General Purpose & Chat
</h5>
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
<div># Latest general models</div>
<div>ollama pull llama3.2:3b</div>
<div>ollama pull mistral:7b</div>
<div>ollama pull phi3.5:3.8b</div>
<div>ollama pull qwen2.5:7b</div>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-purple-500/5 border border-purple-500/20">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-500" />
<span className="font-medium text-purple-500">Performance Optimized</span>
</div>
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
<li> Llama 3.2: 3B - Fastest, 8GB RAM</li>
<li> Phi-3.5: 3.8B - Great balance</li>
<li> Qwen2.5: 7B - Excellent quality</li>
<li> Mistral: 7B - Popular choice</li>
</ul>
</div>
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="font-medium text-yellow-500">Pro Tips</span>
</div>
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
<li> Start with 3B-7B models for best performance</li>
<li> Use quantized versions for faster loading</li>
<li> Desktop app auto-manages model storage</li>
<li> Web UI available at localhost:11434</li>
</ul>
</div>
</div>
</div>
{/* Desktop App Features */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Monitor className="w-4 h-4" />
3. Desktop App Features
</h4>
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="grid md:grid-cols-2 gap-6">
<div>
<h5 className="font-medium text-blue-500 mb-3">🖥 User Interface</h5>
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
<li> Model library browser</li>
<li> One-click model downloads</li>
<li> Built-in chat interface</li>
<li> System resource monitoring</li>
</ul>
</div>
<div>
<h5 className="font-medium text-blue-500 mb-3">🔧 Management Tools</h5>
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
<li> Automatic updates</li>
<li> Model size optimization</li>
<li> GPU acceleration detection</li>
<li> Cross-platform compatibility</li>
</ul>
</div>
</div>
</div>
</div>
{/* Troubleshooting */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Settings className="w-4 h-4" />
4. Troubleshooting & Commands
</h4>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-red-500/5 border border-red-500/20">
<h5 className="font-medium text-red-500 mb-2">Common Issues</h5>
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
<li> Desktop app not starting: Restart system</li>
<li> GPU not detected: Update drivers</li>
<li> Port 11434 blocked: Change port in settings</li>
<li> Models not loading: Check available disk space</li>
<li> Slow performance: Use smaller models or enable GPU</li>
</ul>
</div>
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
<h5 className="font-medium text-green-500 mb-2">Useful Commands</h5>
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
<div># Check installed models</div>
<div>ollama list</div>
<div></div>
<div># Remove unused models</div>
<div>ollama rm model_name</div>
<div></div>
<div># Check GPU usage</div>
<div>ollama ps</div>
<div></div>
<div># View logs</div>
<div>ollama logs</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* LM Studio Setup Section */}
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
<CardHeader className="pb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/20 flex items-center justify-center ring-1 ring-blue-500/30">
<Monitor className="w-6 h-6 text-blue-500" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LM Studio Setup</h3>
<p className="text-sm text-bolt-elements-textSecondary">
User-friendly GUI for running local models with excellent model management
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Installation */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Download className="w-4 h-4" />
1. Download & Install
</h4>
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<p className="text-sm text-bolt-elements-textSecondary mb-3">
Download LM Studio for Windows, macOS, or Linux from the official website.
</p>
<Button
variant="outline"
size="sm"
className="bg-gradient-to-r from-blue-500/10 to-blue-600/10 hover:from-blue-500/20 hover:to-blue-600/20 border-blue-500/30 hover:border-blue-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-blue-500/20 font-medium"
_asChild
>
<a
href="https://lmstudio.ai/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
<span className="flex-1 text-center font-medium">Download LM Studio</span>
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
</a>
</Button>
</div>
</div>
{/* Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Settings className="w-4 h-4" />
2. Configure Local Server
</h4>
<div className="space-y-3">
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Start Local Server</h5>
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
<li>Download a model from the "My Models" tab</li>
<li>Go to "Local Server" tab</li>
<li>Select your downloaded model</li>
<li>Set port to 1234 (default)</li>
<li>Click "Start Server"</li>
</ol>
</div>
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="font-medium text-red-500">Critical: Enable CORS</span>
</div>
<div className="space-y-2">
<p className="text-xs text-bolt-elements-textSecondary">
To work with Bolt DIY, you MUST enable CORS in LM Studio:
</p>
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside ml-2">
<li>In Server Settings, check "Enable CORS"</li>
<li>Set Network Interface to "0.0.0.0" for external access</li>
<li>
Alternatively, use CLI:{' '}
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">lms server start --cors</code>
</li>
</ol>
</div>
</div>
</div>
</div>
{/* Advantages */}
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-blue-500" />
<span className="font-medium text-blue-500">LM Studio Advantages</span>
</div>
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
<li>Built-in model downloader with search</li>
<li>Easy model switching and management</li>
<li>Built-in chat interface for testing</li>
<li>GGUF format support (most compatible)</li>
<li>Regular updates with new features</li>
</ul>
</div>
</CardContent>
</Card>
{/* LocalAI Setup Section */}
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
<CardHeader className="pb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/20 flex items-center justify-center ring-1 ring-green-500/30">
<Globe className="w-6 h-6 text-green-500" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LocalAI Setup</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Self-hosted OpenAI-compatible API server with extensive model support
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Installation */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Download className="w-4 h-4" />
Installation Options
</h4>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Quick Install</h5>
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
<div># One-line install</div>
<div>curl https://localai.io/install.sh | sh</div>
</div>
</div>
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Docker (Recommended)</h5>
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
<div>docker run -p 8080:8080</div>
<div>quay.io/go-skynet/local-ai:latest</div>
</div>
</div>
</div>
</div>
{/* Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<Settings className="w-4 h-4" />
Configuration
</h4>
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
<p className="text-sm text-bolt-elements-textSecondary mb-3">
LocalAI supports many model formats and provides a full OpenAI-compatible API.
</p>
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
<div># Example configuration</div>
<div>models:</div>
<div>- name: llama3.1</div>
<div>backend: llama</div>
<div>parameters:</div>
<div>model: llama3.1.gguf</div>
</div>
</div>
</div>
{/* Advantages */}
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="font-medium text-green-500">LocalAI Advantages</span>
</div>
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
<li>Full OpenAI API compatibility</li>
<li>Supports multiple model formats</li>
<li>Docker deployment option</li>
<li>Built-in model gallery</li>
<li>REST API for model management</li>
</ul>
</div>
</CardContent>
</Card>
{/* Performance Optimization */}
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Activity className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Performance Optimization</h3>
<p className="text-sm text-bolt-elements-textSecondary">Tips to improve local AI performance</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3">
<h4 className="font-medium text-bolt-elements-textPrimary">Hardware Optimizations</h4>
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Use NVIDIA GPU with CUDA for 5-10x speedup</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Increase RAM for larger context windows</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Use SSD storage for faster model loading</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Close other applications to free up RAM</span>
</li>
</ul>
</div>
<div className="space-y-3">
<h4 className="font-medium text-bolt-elements-textPrimary">Software Optimizations</h4>
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>Use smaller models for faster responses</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>Enable quantization (4-bit, 8-bit models)</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>Reduce context length for chat applications</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>Use streaming responses for better UX</span>
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Alternative Options */}
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
<CardHeader className="pb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500/20 to-red-500/20 flex items-center justify-center ring-1 ring-orange-500/30">
<Wifi className="w-6 h-6 text-orange-500" />
</div>
<div>
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Alternative Options</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Other local AI solutions and cloud alternatives
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary">Other Local Solutions</h4>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-blue-500" />
<span className="font-medium text-bolt-elements-textPrimary">Jan.ai</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">
Modern interface with built-in model marketplace
</p>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Terminal className="w-4 h-4 text-green-500" />
<span className="font-medium text-bolt-elements-textPrimary">Oobabooga</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">
Advanced text generation web UI with extensions
</p>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Cable className="w-4 h-4 text-purple-500" />
<span className="font-medium text-bolt-elements-textPrimary">KoboldAI</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">Focus on creative writing and storytelling</p>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="font-medium text-bolt-elements-textPrimary">Cloud Alternatives</h4>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Globe className="w-4 h-4 text-orange-500" />
<span className="font-medium text-bolt-elements-textPrimary">OpenRouter</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">Access to 100+ models through unified API</p>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-red-500" />
<span className="font-medium text-bolt-elements-textPrimary">Together AI</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">Fast inference with open-source models</p>
</div>
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-pink-500" />
<span className="font-medium text-bolt-elements-textPrimary">Groq</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary">Ultra-fast LPU inference for Llama models</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default SetupGuide;

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import { Card, CardContent } from '~/components/ui/Card';
import { Cable, Server, ArrowLeft } from 'lucide-react';
import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
import HealthStatusBadge from './HealthStatusBadge';
import { PROVIDER_ICONS } from './types';
// Status Dashboard Component
function StatusDashboard({ onBack }: { onBack: () => void }) {
const { healthStatuses } = useLocalModelHealth();
return (
<div className="space-y-6">
{/* Header with Back Button */}
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
aria-label="Back to Dashboard"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Provider Status</h2>
<p className="text-sm text-bolt-elements-textSecondary">Monitor the health of your local AI providers</p>
</div>
</div>
{healthStatuses.length === 0 ? (
<Card className="bg-bolt-elements-background-depth-2">
<CardContent className="p-8 text-center">
<Cable className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Endpoints Configured</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Configure and enable local providers to see their endpoint status here.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{healthStatuses.map((status) => (
<Card key={`${status.provider}-${status.baseUrl}`} className="bg-bolt-elements-background-depth-2">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-bolt-elements-background-depth-3 flex items-center justify-center">
{React.createElement(PROVIDER_ICONS[status.provider as keyof typeof PROVIDER_ICONS] || Server, {
className: 'w-5 h-5 text-bolt-elements-textPrimary',
})}
</div>
<div>
<h3 className="font-semibold text-bolt-elements-textPrimary">{status.provider}</h3>
<p className="text-xs text-bolt-elements-textSecondary font-mono">{status.baseUrl}</p>
</div>
</div>
<HealthStatusBadge status={status.status} responseTime={status.responseTime} />
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="text-bolt-elements-textSecondary">Models</div>
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
{status.availableModels?.length || 0}
</div>
</div>
<div className="text-center">
<div className="text-bolt-elements-textSecondary">Version</div>
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
{status.version || 'Unknown'}
</div>
</div>
<div className="text-center">
<div className="text-bolt-elements-textSecondary">Last Check</div>
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
{status.lastChecked ? new Date(status.lastChecked).toLocaleTimeString() : 'Never'}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
export default StatusDashboard;

View File

@@ -1,44 +0,0 @@
// Type definitions
export type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
export interface OllamaModel {
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
error?: string;
newDigest?: string;
progress?: {
current: number;
total: number;
status: string;
};
}
export interface LMStudioModel {
id: string;
object: 'model';
owned_by: string;
created?: number;
}
// Constants
export const OLLAMA_API_URL = 'http://127.0.0.1:11434';
export const PROVIDER_ICONS = {
Ollama: 'Server',
LMStudio: 'Monitor',
OpenAILike: 'Globe',
} as const;
export const PROVIDER_DESCRIPTIONS = {
Ollama: 'Run open-source models locally on your machine',
LMStudio: 'Local model inference with LM Studio',
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
} as const;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,909 +0,0 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import type { VercelUserResponse } from '~/types/vercel';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import { ServiceHeader, ConnectionTestIndicator } from '~/components/@settings/shared/service-integration';
import { useConnectionTest } from '~/lib/hooks';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import Cookies from 'js-cookie';
import {
vercelConnection,
isConnecting,
isFetchingStats,
updateVercelConnection,
fetchVercelStats,
fetchVercelStatsViaAPI,
initializeVercelConnection,
} from '~/lib/stores/vercel';
interface ProjectAction {
name: string;
icon: string;
action: (projectId: string) => Promise<void>;
requiresConfirmation?: boolean;
variant?: 'default' | 'destructive' | 'outline';
}
// Vercel logo SVG component
const VercelLogo = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path fill="currentColor" d="m12 2 10 18H2z" />
</svg>
);
export default function VercelTab() {
const connection = useStore(vercelConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
const [isProjectActionLoading, setIsProjectActionLoading] = useState(false);
// Use shared connection test hook
const {
testResult: connectionTest,
testConnection,
isTestingConnection,
} = useConnectionTest({
testEndpoint: '/api/vercel-user',
serviceName: 'Vercel',
getUserIdentifier: (data: VercelUserResponse) =>
data.username || data.user?.username || data.email || data.user?.email || 'Vercel User',
});
// Memoize project actions to prevent unnecessary re-renders
const projectActions: ProjectAction[] = useMemo(
() => [
{
name: 'Redeploy',
icon: 'i-ph:arrows-clockwise',
action: async (projectId: string) => {
try {
const response = await fetch(`https://api.vercel.com/v1/deployments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectId,
target: 'production',
}),
});
if (!response.ok) {
throw new Error('Failed to redeploy project');
}
toast.success('Project redeployment initiated');
await fetchVercelStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to redeploy project: ${error}`);
}
},
},
{
name: 'View Dashboard',
icon: 'i-ph:layout',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}`, '_blank');
},
},
{
name: 'View Deployments',
icon: 'i-ph:rocket',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}/deployments`, '_blank');
},
},
{
name: 'View Functions',
icon: 'i-ph:code',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}/functions`, '_blank');
},
},
{
name: 'View Analytics',
icon: 'i-ph:chart-bar',
action: async (projectId: string) => {
const project = connection.stats?.projects.find((p) => p.id === projectId);
if (project) {
window.open(`https://vercel.com/${connection.user?.username}/${project.name}/analytics`, '_blank');
}
},
},
{
name: 'View Domains',
icon: 'i-ph:globe',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}/domains`, '_blank');
},
},
{
name: 'View Settings',
icon: 'i-ph:gear',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}/settings`, '_blank');
},
},
{
name: 'View Logs',
icon: 'i-ph:scroll',
action: async (projectId: string) => {
window.open(`https://vercel.com/dashboard/${projectId}/logs`, '_blank');
},
},
{
name: 'Delete Project',
icon: 'i-ph:trash',
action: async (projectId: string) => {
try {
const response = await fetch(`https://api.vercel.com/v1/projects/${projectId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to delete project');
}
toast.success('Project deleted successfully');
await fetchVercelStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete project: ${error}`);
}
},
requiresConfirmation: true,
variant: 'destructive',
},
],
[connection.token],
); // Only re-create when token changes
// Initialize connection on component mount - check server-side token first
useEffect(() => {
const initializeConnection = async () => {
try {
// First try to initialize using server-side token
await initializeVercelConnection();
// If no connection was established, the user will need to manually enter a token
const currentState = vercelConnection.get();
if (!currentState.user) {
console.log('No server-side Vercel token available, manual connection required');
}
} catch (error) {
console.error('Failed to initialize Vercel connection:', error);
}
};
initializeConnection();
}, []);
useEffect(() => {
const fetchProjects = async () => {
if (connection.user) {
// Use server-side API if we have a connected user
try {
await fetchVercelStatsViaAPI(connection.token);
} catch {
// Fallback to direct API if server-side fails and we have a token
if (connection.token) {
await fetchVercelStats(connection.token);
}
}
}
};
fetchProjects();
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const token = connection.token;
if (!token.trim()) {
throw new Error('Token is required');
}
// First test the token directly with Vercel API
const testResponse = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': 'bolt.diy-app',
},
});
if (!testResponse.ok) {
if (testResponse.status === 401) {
throw new Error('Invalid Vercel token');
}
throw new Error(`Vercel API error: ${testResponse.status}`);
}
const userData = (await testResponse.json()) as VercelUserResponse;
// Set cookies for server-side API access
Cookies.set('VITE_VERCEL_ACCESS_TOKEN', token, { expires: 365 });
// Normalize the user data structure
const normalizedUser = userData.user || {
id: userData.id || '',
username: userData.username || '',
email: userData.email || '',
name: userData.name || '',
avatar: userData.avatar,
};
updateVercelConnection({
user: normalizedUser,
token,
});
await fetchVercelStats(token);
toast.success('Successfully connected to Vercel');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Vercel', { error });
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Vercel';
toast.error(errorMessage);
updateVercelConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
// Clear Vercel-related cookies
Cookies.remove('VITE_VERCEL_ACCESS_TOKEN');
updateVercelConnection({ user: null, token: '' });
toast.success('Disconnected from Vercel');
};
const handleProjectAction = useCallback(async (projectId: string, action: ProjectAction) => {
if (action.requiresConfirmation) {
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
return;
}
}
setIsProjectActionLoading(true);
await action.action(projectId);
setIsProjectActionLoading(false);
}, []);
const renderProjects = useCallback(() => {
if (fetchingStats) {
return (
<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 Vercel projects...
</div>
);
}
return (
<Collapsible open={isProjectsExpanded} onOpenChange={setIsProjectsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:buildings w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
Your Projects ({connection.stats?.totalProjects || 0})
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
{/* Vercel Overview Dashboard */}
{connection.stats?.projects?.length ? (
<div className="mb-6 p-4 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Vercel Overview</h4>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{connection.stats.totalProjects}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Total Projects</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{
connection.stats.projects.filter(
(p) => p.targets?.production?.alias && p.targets.production.alias.length > 0,
).length
}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Deployed Projects</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{new Set(connection.stats.projects.map((p) => p.framework).filter(Boolean)).size}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Frameworks Used</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
{connection.stats.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length}
</div>
<div className="text-xs text-bolt-elements-textSecondary">Active Deployments</div>
</div>
</div>
</div>
) : null}
{/* Performance Analytics */}
{connection.stats?.projects?.length ? (
<div className="mb-6 space-y-4">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Performance Analytics</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor">
<h6 className="text-xs font-medium text-bolt-elements-textPrimary flex items-center gap-2 mb-2">
<div className="i-ph:rocket w-4 h-4 text-bolt-elements-item-contentAccent" />
Deployment Health
</h6>
<div className="space-y-1">
{(() => {
const totalDeployments = connection.stats.projects.reduce(
(sum, p) => sum + (p.latestDeployments?.length || 0),
0,
);
const readyDeployments = connection.stats.projects.filter(
(p) => p.latestDeployments?.[0]?.state === 'READY',
).length;
const errorDeployments = connection.stats.projects.filter(
(p) => p.latestDeployments?.[0]?.state === 'ERROR',
).length;
const successRate =
totalDeployments > 0
? Math.round((readyDeployments / connection.stats.projects.length) * 100)
: 0;
return [
{ label: 'Success Rate', value: `${successRate}%` },
{ label: 'Active', value: readyDeployments },
{ label: 'Failed', value: errorDeployments },
];
})().map((item, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-bolt-elements-textSecondary">{item.label}:</span>
<span className="text-bolt-elements-textPrimary font-medium">{item.value}</span>
</div>
))}
</div>
</div>
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor">
<h6 className="text-xs font-medium text-bolt-elements-textPrimary flex items-center gap-2 mb-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
Framework Distribution
</h6>
<div className="space-y-1">
{(() => {
const frameworks = connection.stats.projects.reduce(
(acc, p) => {
if (p.framework) {
acc[p.framework] = (acc[p.framework] || 0) + 1;
}
return acc;
},
{} as Record<string, number>,
);
return Object.entries(frameworks)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([framework, count]) => ({ label: framework, value: count }));
})().map((item, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-bolt-elements-textSecondary">{item.label}:</span>
<span className="text-bolt-elements-textPrimary font-medium">{item.value}</span>
</div>
))}
</div>
</div>
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor">
<h6 className="text-xs font-medium text-bolt-elements-textPrimary flex items-center gap-2 mb-2">
<div className="i-ph:activity w-4 h-4 text-bolt-elements-item-contentAccent" />
Activity Summary
</h6>
<div className="space-y-1">
{(() => {
const now = Date.now();
const recentDeployments = connection.stats.projects.filter((p) => {
const lastDeploy = p.latestDeployments?.[0]?.created;
return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000;
}).length;
const totalDomains = connection.stats.projects.reduce(
(sum, p) => sum + (p.targets?.production?.alias ? p.targets.production.alias.length : 0),
0,
);
const avgDomainsPerProject =
connection.stats.projects.length > 0
? Math.round((totalDomains / connection.stats.projects.length) * 10) / 10
: 0;
return [
{ label: 'Recent deploys', value: recentDeployments },
{ label: 'Total domains', value: totalDomains },
{ label: 'Avg domains/project', value: avgDomainsPerProject },
];
})().map((item, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-bolt-elements-textSecondary">{item.label}:</span>
<span className="text-bolt-elements-textPrimary font-medium">{item.value}</span>
</div>
))}
</div>
</div>
</div>
</div>
) : null}
{/* Project Health Overview */}
{connection.stats?.projects?.length ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Project Health Overview</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{(() => {
const healthyProjects = connection.stats.projects.filter(
(p) =>
p.latestDeployments?.[0]?.state === 'READY' && (p.targets?.production?.alias?.length ?? 0) > 0,
).length;
const needsAttention = connection.stats.projects.filter(
(p) =>
p.latestDeployments?.[0]?.state === 'ERROR' || p.latestDeployments?.[0]?.state === 'CANCELED',
).length;
const withCustomDomain = connection.stats.projects.filter((p) =>
p.targets?.production?.alias?.some((alias: string) => !alias.includes('.vercel.app')),
).length;
const buildingProjects = connection.stats.projects.filter(
(p) => p.latestDeployments?.[0]?.state === 'BUILDING',
).length;
return [
{
label: 'Healthy',
value: healthyProjects,
icon: 'i-ph:check-circle',
color: 'text-green-500',
bgColor: 'bg-green-100 dark:bg-green-900/20',
textColor: 'text-green-800 dark:text-green-400',
},
{
label: 'Custom Domain',
value: withCustomDomain,
icon: 'i-ph:globe',
color: 'text-blue-500',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
textColor: 'text-blue-800 dark:text-blue-400',
},
{
label: 'Building',
value: buildingProjects,
icon: 'i-ph:gear',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
textColor: 'text-yellow-800 dark:text-yellow-400',
},
{
label: 'Issues',
value: needsAttention,
icon: 'i-ph:warning',
color: 'text-red-500',
bgColor: 'bg-red-100 dark:bg-red-900/20',
textColor: 'text-red-800 dark:text-red-400',
},
];
})().map((metric, index) => (
<div
key={index}
className={`flex flex-col p-3 rounded-lg border border-bolt-elements-borderColor ${metric.bgColor}`}
>
<div className="flex items-center gap-2 mb-1">
<div className={`${metric.icon} w-4 h-4 ${metric.color}`} />
<span className="text-xs text-bolt-elements-textSecondary">{metric.label}</span>
</div>
<span className={`text-lg font-medium ${metric.textColor}`}>{metric.value}</span>
</div>
))}
</div>
</div>
) : null}
{connection.stats?.projects?.length ? (
<div className="grid gap-3">
{connection.stats.projects.map((project) => (
<div
key={project.id}
className="p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors bg-bolt-elements-background-depth-1"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<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-bolt-elements-borderColorActive" />
{project.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
<>
<a
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive underline"
>
{project.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
) || project.targets.production.alias[0]}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.createdAt).toLocaleDateString()}
</span>
</>
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
<>
<a
href={`https://${project.latestDeployments[0].url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive underline"
>
{project.latestDeployments[0].url}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
</span>
</>
) : null}
</div>
{/* Project Details Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 mt-3 pt-3 border-t border-bolt-elements-borderColor">
<div className="text-center">
<div className="text-sm font-semibold text-bolt-elements-textPrimary">
{/* Deployments - This would be fetched from API */}
--
</div>
<div className="text-xs text-bolt-elements-textSecondary flex items-center justify-center gap-1">
<div className="i-ph:rocket w-3 h-3" />
Deployments
</div>
</div>
<div className="text-center">
<div className="text-sm font-semibold text-bolt-elements-textPrimary">
{/* Domains - This would be fetched from API */}
--
</div>
<div className="text-xs text-bolt-elements-textSecondary flex items-center justify-center gap-1">
<div className="i-ph:globe w-3 h-3" />
Domains
</div>
</div>
<div className="text-center">
<div className="text-sm font-semibold text-bolt-elements-textPrimary">
{/* Team Members - This would be fetched from API */}
--
</div>
<div className="text-xs text-bolt-elements-textSecondary flex items-center justify-center gap-1">
<div className="i-ph:users w-3 h-3" />
Team
</div>
</div>
<div className="text-center">
<div className="text-sm font-semibold text-bolt-elements-textPrimary">
{/* Bandwidth - This would be fetched from API */}
--
</div>
<div className="text-xs text-bolt-elements-textSecondary flex items-center justify-center gap-1">
<div className="i-ph:activity w-3 h-3" />
Bandwidth
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{project.latestDeployments && project.latestDeployments.length > 0 && (
<div
className={classNames(
'flex items-center gap-1 px-2 py-1 rounded-full text-xs',
project.latestDeployments[0].state === 'READY'
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: project.latestDeployments[0].state === 'ERROR'
? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
)}
>
<div
className={classNames(
'w-2 h-2 rounded-full',
project.latestDeployments[0].state === 'READY'
? 'bg-green-500'
: project.latestDeployments[0].state === 'ERROR'
? 'bg-red-500'
: 'bg-yellow-500',
)}
/>
{project.latestDeployments[0].state}
</div>
)}
{project.framework && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-bolt-elements-background-depth-2">
<span className="flex items-center gap-1">
<div className="i-ph:code w-3 h-3" />
{project.framework}
</span>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => window.open(`https://vercel.com/dashboard/${project.id}`, '_blank')}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<div className="i-ph:arrow-square-out w-3 h-3" />
View
</Button>
</div>
</div>
<div className="flex items-center flex-wrap gap-1 mt-3 pt-3 border-t border-bolt-elements-borderColor">
{projectActions.map((action) => (
<Button
key={action.name}
variant={action.variant || 'outline'}
size="sm"
onClick={() => handleProjectAction(project.id, action)}
disabled={isProjectActionLoading}
className="flex items-center gap-1 text-xs px-2 py-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<div className={`${action.icon} w-2.5 h-2.5`} />
{action.name}
</Button>
))}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2 p-4">
<div className="i-ph:info w-4 h-4" />
No projects found in your Vercel account
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}, [
connection.stats,
fetchingStats,
isProjectsExpanded,
isProjectActionLoading,
handleProjectAction,
projectActions,
]);
console.log('connection', connection);
return (
<div className="space-y-6">
<ServiceHeader
icon={VercelLogo}
title="Vercel Integration"
description="Connect and manage your Vercel projects with advanced deployment controls and analytics"
onTestConnection={connection.user ? () => testConnection() : undefined}
isTestingConnection={isTestingConnection}
/>
<ConnectionTestIndicator testResult={connectionTest} />
{/* Main Connection Component */}
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
{!connection.user ? (
<div className="space-y-4">
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
VITE_VERCEL_ACCESS_TOKEN
</code>{' '}
environment variable to connect automatically.
</p>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateVercelConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Vercel 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-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{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 Vercel
</span>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg">
<img
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username}`}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt="User Avatar"
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.username || 'Vercel User'}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">
{connection.user?.email || 'No email available'}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:buildings w-3 h-3" />
{connection.stats?.totalProjects || 0} Projects
</span>
<span className="flex items-center gap-1">
<div className="i-ph:check-circle w-3 h-3" />
{connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
0}{' '}
Live
</span>
<span className="flex items-center gap-1">
<div className="i-ph:users w-3 h-3" />
{/* Team size would be fetched from API */}
--
</span>
</div>
</div>
</div>
{/* Usage Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-3 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:buildings w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-xs font-medium text-bolt-elements-textPrimary">Projects</span>
</div>
<div className="text-sm text-bolt-elements-textSecondary">
<div>
Active:{' '}
{connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
0}
</div>
<div>Total: {connection.stats?.totalProjects || 0}</div>
</div>
</div>
<div className="p-3 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:globe w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-xs font-medium text-bolt-elements-textPrimary">Domains</span>
</div>
<div className="text-sm text-bolt-elements-textSecondary">
{/* Domain usage would be fetched from API */}
<div>Custom: --</div>
<div>Vercel: --</div>
</div>
</div>
<div className="p-3 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:activity w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-xs font-medium text-bolt-elements-textPrimary">Usage</span>
</div>
<div className="text-sm text-bolt-elements-textSecondary">
{/* Usage metrics would be fetched from API */}
<div>Bandwidth: --</div>
<div>Requests: --</div>
</div>
</div>
</div>
</div>
{renderProjects()}
</div>
)}
</div>
</motion.div>
</div>
);
}

View File

@@ -1,368 +0,0 @@
import React, { useEffect, useState, useRef } 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 {
vercelConnection,
isConnecting,
isFetchingStats,
updateVercelConnection,
fetchVercelStats,
autoConnectVercel,
} from '~/lib/stores/vercel';
export default function VercelConnection() {
console.log('VercelConnection component mounted');
const connection = useStore(vercelConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
const hasInitialized = useRef(false);
console.log('VercelConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
useEffect(() => {
// Prevent multiple initializations
if (hasInitialized.current) {
console.log('Vercel: Already initialized, skipping');
return;
}
const initializeConnection = async () => {
console.log('Vercel initializeConnection:', {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
hasInitialized.current = true;
// Auto-connect using environment variable if no existing connection but token exists
if (!connection.user && connection.token && import.meta.env?.VITE_VERCEL_ACCESS_TOKEN) {
console.log('Vercel: Attempting auto-connection');
const result = await autoConnectVercel();
if (result.success) {
toast.success('Connected to Vercel automatically');
} else {
console.error('Vercel auto-connection failed:', result.error);
}
} else if (connection.user && connection.token) {
// Fetch stats for existing connection
console.log('Vercel: Fetching stats for existing connection');
await fetchVercelStats(connection.token);
} else {
console.log('Vercel: No auto-connection conditions met');
}
};
initializeConnection();
}, []); // Empty dependency array to run only once
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const response = await fetch('https://api.vercel.com/v2/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 any;
updateVercelConnection({
user: userData.user || userData, // Handle both possible structures
token: connection.token,
});
await fetchVercelStats(connection.token);
toast.success('Successfully connected to Vercel');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Vercel', { error });
toast.error('Failed to connect to Vercel');
updateVercelConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateVercelConnection({ user: null, token: '' });
toast.success('Disconnected from Vercel');
};
console.log('connection', connection);
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 dark:invert"
height="24"
width="24"
crossOrigin="anonymous"
src={`https://cdn.simpleicons.org/vercel/black`}
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel 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) => updateVercelConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Vercel 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-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<div className="mt-2 text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-2 rounded">
<p className="flex items-center gap-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded text-xs">
VITE_VERCEL_ACCESS_TOKEN
</code>{' '}
in your .env.local for automatic connection.
</p>
</div>
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual auto-connect test');
const result = await autoConnectVercel();
if (result.success) {
toast.success('Manual auto-connect successful');
} else {
toast.error(`Manual auto-connect failed: ${result.error}`);
}
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (
<div className="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 Vercel
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
{/* Debug output */}
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
<img
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt="User Avatar"
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">
{connection.user?.email || connection.user?.user?.email || 'No email available'}
</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 Vercel projects...
</div>
) : (
<div>
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
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 Projects ({connection.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isProjectsExpanded && connection.stats?.projects?.length ? (
<div className="grid gap-3">
{connection.stats.projects.map((project) => (
<a
key={project.id}
href={`https://vercel.com/dashboard/${project.id}`}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive 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-bolt-elements-borderColorActive" />
{project.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
<>
<a
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
) || project.targets.production.alias[0]}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.createdAt).toLocaleDateString()}
</span>
</>
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
<>
<a
href={`https://${project.latestDeployments[0].url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.latestDeployments[0].url}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
</span>
</>
) : null}
</div>
</div>
{project.framework && (
<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:code w-3 h-3" />
{project.framework}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isProjectsExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No projects found in your Vercel account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1 +0,0 @@
export { default as VercelConnection } from './VercelConnection';

View File

@@ -1,54 +0,0 @@
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
export const getVisibleTabs = (
tabConfiguration: { userTabs: TabVisibilityConfig[] },
notificationsEnabled: boolean,
): TabVisibilityConfig[] => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, using defaults');
return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
}
// In user mode, only show visible user tabs
return tabConfiguration.userTabs
.filter((tab) => {
if (!tab || typeof tab.id !== 'string') {
console.warn('Invalid tab entry:', tab);
return false;
}
// Hide notifications tab if notifications are disabled
if (tab.id === 'notifications' && !notificationsEnabled) {
return false;
}
// Only show tabs that are explicitly visible and assigned to the user window
return tab.visible && tab.window === 'user';
})
.sort((a, b) => a.order - b.order);
};
export const reorderTabs = (
tabs: TabVisibilityConfig[],
startIndex: number,
endIndex: number,
): TabVisibilityConfig[] => {
const result = Array.from(tabs);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
// Update order property
return result.map((tab, index) => ({
...tab,
order: index,
}));
};
export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
return DEFAULT_TAB_CONFIG.map((tab) => ({
...tab,
visible: isDeveloperMode ? true : tab.window === 'user',
window: isDeveloperMode ? 'developer' : tab.window,
})) as TabVisibilityConfig[];
};

View File

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

View File

@@ -23,24 +23,19 @@ if (import.meta.hot) {
interface ArtifactProps {
messageId: string;
artifactId: string;
}
export const Artifact = memo(({ artifactId }: ArtifactProps) => {
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[artifactId];
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
// Filter out Supabase actions except for migrations
return Object.values(actions).filter((action) => {
// Exclude actions with type 'supabase' or actions that contain 'supabase' in their content
return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase'));
});
return Object.values(actions);
}),
);
@@ -55,105 +50,77 @@ export const Artifact = memo(({ artifactId }: ArtifactProps) => {
}
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find(
(action) => action.status !== 'complete' && !(action.type === 'start' && action.status === 'running'),
);
const finished = !actions.find((action) => action.status !== 'complete');
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions, artifact.type, allActionFinished]);
// Determine the dynamic title based on state for bundled artifacts
const dynamicTitle =
artifact?.type === 'bundled'
? allActionFinished
? artifact.id === 'restored-project-setup'
? 'Project Restored' // Title when restore is complete
: 'Project Created' // Title when initial creation is complete
: artifact.id === 'restored-project-setup'
? 'Restoring Project...' // Title during restore
: 'Creating Project...' // Title during initial creation
: artifact?.title; // Fallback to original title for non-bundled or if artifact is missing
}, [actions]);
return (
<>
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
{/* Use the dynamic title here */}
{dynamicTitle}
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
Click to open Workbench
</div>
</div>
</button>
{artifact.type !== 'bundled' && <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />}
<AnimatePresence>
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
{artifact.type === 'bundled' && (
<div className="flex items-center gap-1.5 p-5 bg-bolt-elements-actions-background border-t border-bolt-elements-artifacts-borderColor">
<div className={classNames('text-lg', getIconColor(allActionFinished ? 'complete' : 'running'))}>
{allActionFinished ? (
<div className="i-ph:check"></div>
) : (
<div className="i-svg-spinners:90-ring-with-bg"></div>
)}
</div>
<div className="text-bolt-elements-textPrimary font-medium leading-5 text-sm">
{/* This status text remains the same */}
{allActionFinished
? artifact.id === 'restored-project-setup'
? 'Restore files from snapshot'
: 'Initial files created'
: 'Creating initial files'}
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
</div>
)}
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.div>
</motion.button>
)}
</AnimatePresence>
</div>
</>
<AnimatePresence>
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
@@ -185,7 +152,7 @@ const actionVariants = {
visible: { opacity: 1, y: 0 },
};
export function openArtifactInWorkbench(filePath: any) {
function openArtifactInWorkbench(filePath: any) {
if (workbenchStore.currentView.get() !== 'code') {
workbenchStore.currentView.set('code');
}

View File

@@ -1,192 +1,31 @@
import { memo, Fragment } from 'react';
import { memo } from 'react';
import { Markdown } from './Markdown';
import type { JSONValue } from 'ai';
import Popover from '~/components/ui/Popover';
import { workbenchStore } from '~/lib/stores/workbench';
import { WORK_DIR } from '~/utils/constants';
import WithTooltip from '~/components/ui/Tooltip';
import type { Message } from 'ai';
import type { ProviderInfo } from '~/types/model';
import type {
TextUIPart,
ReasoningUIPart,
ToolInvocationUIPart,
SourceUIPart,
FileUIPart,
StepStartUIPart,
} from '@ai-sdk/ui-utils';
import { ToolInvocations } from './ToolInvocations';
import type { ToolCallAnnotation } from '~/types/context';
interface AssistantMessageProps {
content: string;
annotations?: JSONValue[];
messageId?: string;
onRewind?: (messageId: string) => void;
onFork?: (messageId: string) => void;
append?: (message: Message) => void;
chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void;
model?: string;
provider?: ProviderInfo;
parts:
| (TextUIPart | ReasoningUIPart | ToolInvocationUIPart | SourceUIPart | FileUIPart | StepStartUIPart)[]
| undefined;
addToolResult: ({ toolCallId, result }: { toolCallId: string; result: any }) => void;
}
function openArtifactInWorkbench(filePath: string) {
filePath = normalizedFilePath(filePath);
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
const filteredAnnotations = (annotations?.filter(
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
) || []) as { type: string; value: any }[];
if (workbenchStore.currentView.get() !== 'code') {
workbenchStore.currentView.set('code');
}
const usage: {
completionTokens: number;
promptTokens: number;
totalTokens: number;
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
}
function normalizedFilePath(path: string) {
let normalizedPath = path;
if (normalizedPath.startsWith(WORK_DIR)) {
normalizedPath = path.replace(WORK_DIR, '');
}
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.slice(1);
}
return normalizedPath;
}
export const AssistantMessage = memo(
({
content,
annotations,
messageId,
onRewind,
onFork,
append,
chatMode,
setChatMode,
model,
provider,
parts,
addToolResult,
}: AssistantMessageProps) => {
const filteredAnnotations = (annotations?.filter(
(annotation: JSONValue) =>
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
) || []) as { type: string; value: any } & { [key: string]: any }[];
let chatSummary: string | undefined = undefined;
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
}
let codeContext: string[] | undefined = undefined;
if (filteredAnnotations.find((annotation) => annotation.type === 'codeContext')) {
codeContext = filteredAnnotations.find((annotation) => annotation.type === 'codeContext')?.files;
}
const usage: {
completionTokens: number;
promptTokens: number;
totalTokens: number;
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
const toolInvocations = parts?.filter((part) => part.type === 'tool-invocation');
const toolCallAnnotations = filteredAnnotations.filter(
(annotation) => annotation.type === 'toolCall',
) as ToolCallAnnotation[];
return (
<div className="overflow-hidden w-full">
<>
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
{(codeContext || chatSummary) && (
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
{chatSummary && (
<div className="max-w-chat">
<div className="summary max-h-96 flex flex-col">
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
<Markdown>{chatSummary}</Markdown>
</div>
</div>
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<Fragment key={normalized}>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</Fragment>
);
})}
</div>
</div>
)}
</div>
)}
<div className="context"></div>
</Popover>
)}
<div className="flex w-full items-center justify-between">
{usage && (
<div>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
{(onRewind || onFork) && messageId && (
<div className="flex gap-2 flex-col lg:flex-row ml-auto">
{onRewind && (
<WithTooltip tooltip="Revert to this message">
<button
onClick={() => onRewind(messageId)}
key="i-ph:arrow-u-up-left"
className="i-ph:arrow-u-up-left text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
{onFork && (
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => onFork(messageId)}
key="i-ph:git-fork"
className="i-ph:git-fork text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
</div>
)}
</div>
</div>
</>
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
{content}
</Markdown>
{toolInvocations && toolInvocations.length > 0 && (
<ToolInvocations
toolInvocations={toolInvocations}
toolCallAnnotations={toolCallAnnotations}
addToolResult={addToolResult}
/>
)}
</div>
);
},
);
return (
<div className="overflow-hidden w-full">
{usage && (
<div className="text-sm text-bolt-elements-textSecondary mb-2">
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
<Markdown html>{content}</Markdown>
</div>
);
});

View File

@@ -2,37 +2,32 @@
* @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation.
*/
import type { JSONValue, Message } from 'ai';
import type { Message } from 'ai';
import React, { type RefCallback, useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { PROVIDER_LIST } from '~/utils/constants';
import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
import { Messages } from './Messages.client';
import { getApiKeysFromCookies } from './APIKeyManager';
import { SendButton } from './SendButton.client';
import { APIKeyManager } from './APIKeyManager';
import Cookies from 'js-cookie';
import * as Tooltip from '@radix-ui/react-tooltip';
import styles from './BaseChat.module.scss';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import GitCloneButton from './GitCloneButton';
import type { ProviderInfo } from '~/types/model';
import StarterTemplates from './StarterTemplates';
import type { ActionAlert, SupabaseAlert, DeployAlert, LlmErrorAlertType } from '~/types/actions';
import DeployChatAlert from '~/components/deploy/DeployAlert';
import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
import { useStore } from '@nanostores/react';
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
import { ChatBox } from './ChatBox';
import type { DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
import LlmErrorAlert from './LLMApiAlert';
import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -43,7 +38,6 @@ interface BaseChatProps {
showChat?: boolean;
chatStarted?: boolean;
isStreaming?: boolean;
onStreamingChange?: (streaming: boolean) => void;
messages?: Message[];
description?: string;
enhancingPrompt?: boolean;
@@ -64,34 +58,17 @@ interface BaseChatProps {
setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void;
actionAlert?: ActionAlert;
clearAlert?: () => void;
supabaseAlert?: SupabaseAlert;
clearSupabaseAlert?: () => void;
deployAlert?: DeployAlert;
clearDeployAlert?: () => void;
llmErrorAlert?: LlmErrorAlertType;
clearLlmErrorAlert?: () => void;
data?: JSONValue[] | undefined;
chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void;
append?: (message: Message) => void;
designScheme?: DesignScheme;
setDesignScheme?: (scheme: DesignScheme) => void;
selectedElement?: ElementInfo | null;
setSelectedElement?: (element: ElementInfo | null) => void;
addToolResult?: ({ toolCallId, result }: { toolCallId: string; result: any }) => void;
onWebSearchResult?: (result: string) => void;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
(
{
textareaRef,
messageRef,
scrollRef,
showChat = true,
chatStarted = false,
isStreaming = false,
onStreamingChange,
model,
setModel,
provider,
@@ -112,64 +89,76 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
imageDataList = [],
setImageDataList,
messages,
actionAlert,
clearAlert,
deployAlert,
clearDeployAlert,
supabaseAlert,
clearSupabaseAlert,
llmErrorAlert,
clearLlmErrorAlert,
data,
chatMode,
setChatMode,
append,
designScheme,
setDesignScheme,
selectedElement,
setSelectedElement,
addToolResult = () => {
throw new Error('addToolResult not implemented');
},
onWebSearchResult,
},
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies());
const [modelList, setModelList] = useState<ModelInfo[]>([]);
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
const savedKeys = Cookies.get('apiKeys');
if (savedKeys) {
try {
return JSON.parse(savedKeys);
} catch (error) {
console.error('Failed to parse API keys from cookies:', error);
return {};
}
}
return {};
});
const [modelList, setModelList] = useState(MODEL_LIST);
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState('');
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
const expoUrl = useStore(expoUrlAtom);
const [qrModalOpen, setQrModalOpen] = useState(false);
useEffect(() => {
if (expoUrl) {
setQrModalOpen(true);
}
}, [expoUrl]);
useEffect(() => {
if (data) {
const progressList = data.filter(
(x) => typeof x === 'object' && (x as any).type === 'progress',
) as ProgressAnnotation[];
setProgressAnnotations(progressList);
}
}, [data]);
useEffect(() => {
console.log(transcript);
}, [transcript]);
useEffect(() => {
onStreamingChange?.(isStreaming);
}, [isStreaming, onStreamingChange]);
// Load API keys from cookies on component mount
try {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
const parsedKeys = JSON.parse(storedApiKeys);
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
setApiKeys(parsedKeys);
}
}
} catch (error) {
console.error('Error loading API keys from cookies:', error);
// Clear invalid cookie data
Cookies.remove('apiKeys');
}
let providerSettings: Record<string, IProviderSetting> | undefined = undefined;
try {
const savedProviderSettings = Cookies.get('providers');
if (savedProviderSettings) {
const parsedProviderSettings = JSON.parse(savedProviderSettings);
if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) {
providerSettings = parsedProviderSettings;
}
}
} catch (error) {
console.error('Error loading Provider Settings from cookies:', error);
// Clear invalid cookie data
Cookies.remove('providers');
}
initializeModelList(providerSettings).then((modelList) => {
setModelList(modelList);
});
useEffect(() => {
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
@@ -201,59 +190,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
let parsedApiKeys: Record<string, string> | undefined = {};
try {
parsedApiKeys = getApiKeysFromCookies();
setApiKeys(parsedApiKeys);
} catch (error) {
console.error('Error loading API keys from cookies:', error);
Cookies.remove('apiKeys');
}
setIsModelLoading('all');
fetch('/api/models')
.then((response) => response.json())
.then((data) => {
const typedData = data as { modelList: ModelInfo[] };
setModelList(typedData.modelList);
})
.catch((error) => {
console.error('Error fetching model list:', error);
})
.finally(() => {
setIsModelLoading(undefined);
});
}
}, [providerList, provider]);
const onApiKeysChange = async (providerName: string, apiKey: string) => {
const newApiKeys = { ...apiKeys, [providerName]: apiKey };
setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
setIsModelLoading(providerName);
let providerModels: ModelInfo[] = [];
try {
const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
const data = await response.json();
providerModels = (data as { modelList: ModelInfo[] }).modelList;
} catch (error) {
console.error('Error loading dynamic models for:', providerName, error);
}
// Only update models for the specific provider
setModelList((prevModels) => {
const otherModels = prevModels.filter((model) => model.provider !== providerName);
return [...otherModels, ...providerModels];
});
setIsModelLoading(undefined);
};
const startListening = () => {
if (recognition) {
recognition.start();
@@ -271,7 +207,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
if (sendMessage) {
sendMessage(event, messageInput);
setSelectedElement?.(null);
if (recognition) {
recognition.abort(); // Stop current recognition
@@ -348,10 +283,10 @@ 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-2xl mx-auto text-center px-4 lg:px-0">
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
Where ideas begin
</h1>
@@ -360,143 +295,265 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</p>
</div>
)}
<StickToBottom
className={classNames('pt-6 px-2 sm:px-6 relative', {
'h-full flex flex-col modern-scrollbar': chatStarted,
<div
className={classNames('pt-6 px-2 sm:px-6', {
'h-full flex flex-col': chatStarted,
})}
resize="smooth"
initial="smooth"
>
<StickToBottom.Content className="flex flex-col gap-4 relative ">
<ClientOnly>
{() => {
return chatStarted ? (
<Messages
className="flex flex-col w-full flex-1 max-w-chat pb-4 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
append={append}
chatMode={chatMode}
setChatMode={setChatMode}
provider={provider}
model={model}
addToolResult={addToolResult}
/>
) : null;
}}
</ClientOnly>
<ScrollToBottom />
</StickToBottom.Content>
<ClientOnly>
{() => {
return chatStarted ? (
<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('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
})}
className={classNames(
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
{
'sticky bottom-2': chatStarted,
},
)}
>
<div className="flex flex-col gap-2">
{deployAlert && (
<DeployChatAlert
alert={deployAlert}
clearAlert={() => clearDeployAlert?.()}
postMessage={(message: string | undefined) => {
sendMessage?.({} as any, message);
clearSupabaseAlert?.();
}}
<svg className={classNames(styles.PromptEffectContainer)}>
<defs>
<linearGradient
id="line-gradient"
x1="20%"
y1="0%"
x2="-14%"
y2="10%"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(-45)"
>
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
</linearGradient>
<linearGradient id="shine-gradient">
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
</linearGradient>
</defs>
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
</svg>
<div>
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
<ModelSelector
key={provider?.name + ':' + modelList.length}
model={model}
setModel={setModel}
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={providerList || PROVIDER_LIST}
apiKeys={apiKeys}
/>
)}
{supabaseAlert && (
<SupabaseChatAlert
alert={supabaseAlert}
clearAlert={() => clearSupabaseAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearSupabaseAlert?.();
}}
/>
)}
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
{llmErrorAlert && <LlmErrorAlert alert={llmErrorAlert} clearAlert={() => clearLlmErrorAlert?.()} />}
{(providerList || []).length > 0 && provider && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => {
const newApiKeys = { ...apiKeys, [provider.name]: key };
setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
}}
/>
)}
</div>
</div>
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<ChatBox
isModelSettingsCollapsed={isModelSettingsCollapsed}
setIsModelSettingsCollapsed={setIsModelSettingsCollapsed}
provider={provider}
setProvider={setProvider}
providerList={providerList || (PROVIDER_LIST as ProviderInfo[])}
model={model}
setModel={setModel}
modelList={modelList}
apiKeys={apiKeys}
isModelLoading={isModelLoading}
onApiKeysChange={onApiKeysChange}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
<FilePreview
files={uploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
textareaRef={textareaRef}
input={input}
handleInputChange={handleInputChange}
handlePaste={handlePaste}
TEXTAREA_MIN_HEIGHT={TEXTAREA_MIN_HEIGHT}
TEXTAREA_MAX_HEIGHT={TEXTAREA_MAX_HEIGHT}
isStreaming={isStreaming}
handleStop={handleStop}
handleSendMessage={handleSendMessage}
enhancingPrompt={enhancingPrompt}
enhancePrompt={enhancePrompt}
isListening={isListening}
startListening={startListening}
stopListening={stopListening}
chatStarted={chatStarted}
exportChat={exportChat}
qrModalOpen={qrModalOpen}
setQrModalOpen={setQrModalOpen}
handleFileUpload={handleFileUpload}
chatMode={chatMode}
setChatMode={setChatMode}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
onWebSearchResult={onWebSearchResult}
onRemove={(index) => {
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
}}
/>
</div>
</StickToBottom>
<div className="flex flex-col justify-center">
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
)}
<div className="flex flex-col gap-5">
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {
handleStop?.();
return;
}
<ClientOnly>
{() => (
<ScreenshotStateManager
setUploadedFiles={setUploadedFiles}
setImageDataList={setImageDataList}
uploadedFiles={uploadedFiles}
imageDataList={imageDataList}
/>
)}
</ClientOnly>
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
)}
>
<textarea
ref={textareaRef}
className={classNames(
'w-full pl-4 pt-4 pr-16 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
'transition-all duration-200',
'hover:border-bolt-elements-focus',
)}
onDragEnter={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragLeave={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
handleSendMessage?.(event, messageInput);
})}
{!chatStarted && <StarterTemplates />}
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const base64Image = e.target?.result as string;
setUploadedFiles?.([...uploadedFiles, file]);
setImageDataList?.([...imageDataList, base64Image]);
};
reader.readAsDataURL(file);
}
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
if (isStreaming) {
handleStop?.();
return;
}
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
handleSendMessage?.(event);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
onPaste={handlePaste}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder="How can Bolt help you today?"
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
isStreaming={isStreaming}
disabled={!providerList || providerList.length === 0}
onClick={(event) => {
if (isStreaming) {
handleStop?.();
return;
}
if (input.length > 0 || uploadedFiles.length > 0) {
handleSendMessage?.(event);
}
}}
/>
)}
</ClientOnly>
<div className="flex justify-between items-center text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div>
</IconButton>
<IconButton
title="Enhance prompt"
disabled={input.length === 0 || enhancingPrompt}
className={classNames('transition-all', enhancingPrompt ? 'opacity-100' : '')}
onClick={() => {
enhancePrompt?.();
toast.success('Prompt enhanced!');
}}
>
{enhancingPrompt ? (
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
) : (
<div className="i-bolt:stars text-xl"></div>
)}
</IconButton>
<SpeechRecognitionButton
isListening={isListening}
onStart={startListening}
onStop={stopListening}
disabled={isStreaming}
/>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
<IconButton
title="Model Settings"
className={classNames('transition-all flex items-center gap-1', {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
isModelSettingsCollapsed,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
!isModelSettingsCollapsed,
})}
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
disabled={!providerList || providerList.length === 0}
>
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
new line
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
<ClientOnly>
{() => (
<Workbench chatStarted={chatStarted} isStreaming={isStreaming} setSelectedElement={setSelectedElement} />
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
)}
</ClientOnly>
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {
handleStop?.();
return;
}
handleSendMessage?.(event, messageInput);
})}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>
</div>
);
@@ -504,22 +561,3 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
},
);
function ScrollToBottom() {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
return (
!isAtBottom && (
<>
<div className="sticky bottom-0 left-0 right-0 bg-gradient-to-t from-bolt-elements-background-depth-1 to-transparent h-20 z-10" />
<button
className="sticky z-50 bottom-0 left-0 right-0 text-4xl rounded-lg px-1.5 py-0.5 flex items-center justify-center mx-auto gap-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
onClick={() => scrollToBottom()}
>
Go to last message
<span className="i-ph:arrow-down animate-bounce" />
</button>
</>
)
);
}

View File

@@ -1,10 +1,14 @@
/*
* @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation.
*/
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from '@ai-sdk/react';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { description, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
@@ -17,17 +21,11 @@ import { debounce } from '~/utils/debounce';
import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
import type { TextUIPart, FileUIPart, Attachment } from '@ai-sdk/ui-utils';
import { useMCPStore } from '~/lib/stores/mcp';
import type { LlmErrorAlertType } from '~/types/actions';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat');
@@ -36,9 +34,6 @@ export function Chat() {
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
const title = useStore(description);
useEffect(() => {
workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id));
}, [initialMessages]);
return (
<>
@@ -51,28 +46,37 @@ export function Chat() {
importChat={importChat}
/>
)}
<ToastContainer
closeButton={({ closeToast }) => {
return (
<button className="Toastify__close-button" onClick={closeToast}>
<div className="i-ph:x text-lg" />
</button>
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
}
case 'error': {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}
return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
/>
</>
);
}
const processSampledMessages = createSampler(
(options: {
messages: Message[];
initialMessages: Message[];
isLoading: boolean;
parseMessages: (messages: Message[], isLoading: boolean) => void;
storeMessageHistory: (messages: Message[]) => Promise<void>;
}) => {
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
},
50,
);
interface ChatProps {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
@@ -87,88 +91,48 @@ export const ChatImpl = memo(
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
const actionAlert = useStore(workbenchStore.alert);
const deployAlert = useStore(workbenchStore.deployAlert);
const supabaseConn = useStore(supabaseConnection);
const selectedProject = supabaseConn.stats?.projects?.find(
(project) => project.id === supabaseConn.selectedProjectId,
);
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [llmErrorAlert, setLlmErrorAlert] = useState<LlmErrorAlertType | undefined>(undefined);
const { activeProviders, promptId } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL;
});
const [provider, setProvider] = useState(() => {
const savedProvider = Cookies.get('selectedProvider');
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
});
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build');
const [selectedElement, setSelectedElement] = useState<ElementInfo | null>(null);
const mcpSettings = useMCPStore((state) => state.settings);
const {
messages,
isLoading,
input,
handleInputChange,
setInput,
stop,
append,
setMessages,
reload,
error,
data: chatData,
setData,
addToolResult,
} = useChat({
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
api: '/api/chat',
body: {
apiKeys,
files,
promptId,
contextOptimization: contextOptimizationEnabled,
chatMode,
designScheme,
supabase: {
isConnected: supabaseConn.isConnected,
hasSelectedProject: !!selectedProject,
credentials: {
supabaseUrl: supabaseConn?.credentials?.supabaseUrl,
anonKey: supabaseConn?.credentials?.anonKey,
},
},
maxLLMSteps: mcpSettings.maxLLMSteps,
},
sendExtraMessageFields: true,
onError: (e) => {
setFakeLoading(false);
handleError(e, 'chat');
onError: (error) => {
logger.error('Request failed\n\n', error);
toast.error(
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
);
},
onFinish: (message, response) => {
const usage = response.usage;
setData(undefined);
if (usage) {
console.log('Token usage:', usage);
logStore.logProvider('Chat response completed', {
component: 'Chat',
action: 'response',
model,
provider: provider.name,
usage,
messageLength: message.content.length,
});
// You can now use the usage data as needed
}
logger.debug('Finished streaming');
@@ -178,15 +142,19 @@ export const ChatImpl = memo(
});
useEffect(() => {
const prompt = searchParams.get('prompt');
// console.log(prompt, searchParams, model, provider);
console.log(prompt, searchParams, model, provider);
if (prompt) {
setSearchParams({});
runAnimation();
append({
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
},
] as any, // Type assertion to bypass compiler check
});
}
}, [model, provider, searchParams]);
@@ -201,13 +169,11 @@ export const ChatImpl = memo(
}, []);
useEffect(() => {
processSampledMessages({
messages,
initialMessages,
isLoading,
parseMessages,
storeMessageHistory,
});
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
@@ -222,89 +188,8 @@ export const ChatImpl = memo(
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
logStore.logProvider('Chat response aborted', {
component: 'Chat',
action: 'abort',
model,
provider: provider.name,
});
};
const handleError = useCallback(
(error: any, context: 'chat' | 'template' | 'llmcall' = 'chat') => {
logger.error(`${context} request failed`, error);
stop();
setFakeLoading(false);
let errorInfo = {
message: 'An unexpected error occurred',
isRetryable: true,
statusCode: 500,
provider: provider.name,
type: 'unknown' as const,
retryDelay: 0,
};
if (error.message) {
try {
const parsed = JSON.parse(error.message);
if (parsed.error || parsed.message) {
errorInfo = { ...errorInfo, ...parsed };
} else {
errorInfo.message = error.message;
}
} catch {
errorInfo.message = error.message;
}
}
let errorType: LlmErrorAlertType['errorType'] = 'unknown';
let title = 'Request Failed';
if (errorInfo.statusCode === 401 || errorInfo.message.toLowerCase().includes('api key')) {
errorType = 'authentication';
title = 'Authentication Error';
} else if (errorInfo.statusCode === 429 || errorInfo.message.toLowerCase().includes('rate limit')) {
errorType = 'rate_limit';
title = 'Rate Limit Exceeded';
} else if (errorInfo.message.toLowerCase().includes('quota')) {
errorType = 'quota';
title = 'Quota Exceeded';
} else if (errorInfo.statusCode >= 500) {
errorType = 'network';
title = 'Server Error';
}
logStore.logError(`${context} request failed`, error, {
component: 'Chat',
action: 'request',
error: errorInfo.message,
context,
retryable: errorInfo.isRetryable,
errorType,
provider: provider.name,
});
// Create API error alert
setLlmErrorAlert({
type: 'error',
title,
description: errorInfo.message,
provider: provider.name,
errorType,
});
setData([]);
},
[provider.name, stop],
);
const clearApiErrorAlert = useCallback(() => {
setLlmErrorAlert(undefined);
}, []);
useEffect(() => {
const textarea = textareaRef.current;
@@ -333,221 +218,75 @@ export const ChatImpl = memo(
setChatStarted(true);
};
// Helper function to create message parts array from text and images
const createMessageParts = (text: string, images: string[] = []): Array<TextUIPart | FileUIPart> => {
// Create an array of properly typed message parts
const parts: Array<TextUIPart | FileUIPart> = [
{
type: 'text',
text,
},
];
// Add image parts if any
images.forEach((imageData) => {
// Extract correct MIME type from the data URL
const mimeType = imageData.split(';')[0].split(':')[1] || 'image/jpeg';
// Create file part according to AI SDK format
parts.push({
type: 'file',
mimeType,
data: imageData.replace(/^data:image\/[^;]+;base64,/, ''),
});
});
return parts;
};
// Helper function to convert File[] to Attachment[] for AI SDK
const filesToAttachments = async (files: File[]): Promise<Attachment[] | undefined> => {
if (files.length === 0) {
return undefined;
}
const attachments = await Promise.all(
files.map(
(file) =>
new Promise<Attachment>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve({
name: file.name,
contentType: file.type,
url: reader.result as string,
});
};
reader.readAsDataURL(file);
}),
),
);
return attachments;
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const messageContent = messageInput || input;
const _input = messageInput || input;
if (!messageContent?.trim()) {
if (_input.length === 0 || isLoading) {
return;
}
if (isLoading) {
abort();
return;
}
/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* of some kind so the user is aware that something is happening. But I consider the
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();
let finalMessageContent = messageContent;
if (selectedElement) {
console.log('Selected Element:', selectedElement);
const elementInfo = `<div class=\"__boltSelectedElement__\" data-element='${JSON.stringify(selectedElement)}'>${JSON.stringify(`${selectedElement.displayText}`)}</div>`;
finalMessageContent = messageContent + elementInfo;
}
runAnimation();
if (!chatStarted) {
setFakeLoading(true);
if (autoSelectTemplate) {
const { template, title } = await selectStarterTemplate({
message: finalMessageContent,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title).catch((e) => {
if (e.message.includes('rate limit')) {
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
} else {
toast.warning('Failed to import starter template\n Continuing with blank template');
}
return null;
});
if (temResp) {
const { assistantMessage, userMessage } = temResp;
const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
setMessages([
{
id: `1-${new Date().getTime()}`,
role: 'user',
content: userMessageText,
parts: createMessageParts(userMessageText, imageDataList),
},
{
id: `2-${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `3-${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
const reloadOptions =
uploadedFiles.length > 0
? { experimental_attachments: await filesToAttachments(uploadedFiles) }
: undefined;
reload(reloadOptions);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
setFakeLoading(false);
return;
}
}
}
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
const attachments = uploadedFiles.length > 0 ? await filesToAttachments(uploadedFiles) : undefined;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: userMessageText,
parts: createMessageParts(userMessageText, imageDataList),
experimental_attachments: attachments,
},
]);
reload(attachments ? { experimental_attachments: attachments } : undefined);
setFakeLoading(false);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
return;
}
if (error != null) {
setMessages(messages.slice(0, -1));
}
const modifiedFiles = workbenchStore.getModifiedFiles();
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
if (modifiedFiles !== undefined) {
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`;
runAnimation();
const attachmentOptions =
uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined;
append(
{
role: 'user',
content: messageText,
parts: createMessageParts(messageText, imageDataList),
},
attachmentOptions,
);
if (fileModifications !== undefined) {
/**
* If we have file modifications we append a new user message manually since we have to prefix
* the user input with the file modifications and we don't want the new user input to appear
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
* manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here.
*/
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
});
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
const attachmentOptions =
uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined;
append(
{
role: 'user',
content: messageText,
parts: createMessageParts(messageText, imageDataList),
},
attachmentOptions,
);
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
});
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
// Add file cleanup here
setUploadedFiles([]);
setImageDataList([]);
@@ -576,6 +315,8 @@ export const ChatImpl = memo(
[],
);
const [messageRef, scrollRef] = useSnapScroll();
useEffect(() => {
const storedApiKeys = Cookies.get('apiKeys');
@@ -594,20 +335,6 @@ export const ChatImpl = memo(
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
};
const handleWebSearchResult = useCallback(
(result: string) => {
const currentInput = input || '';
const newInput = currentInput.length > 0 ? `${result}\n\n${currentInput}` : result;
// Update the input via the same mechanism as handleInputChange
const syntheticEvent = {
target: { value: newInput },
} as React.ChangeEvent<HTMLTextAreaElement>;
handleInputChange(syntheticEvent);
},
[input, handleInputChange],
);
return (
<BaseChat
ref={animationScope}
@@ -615,10 +342,7 @@ export const ChatImpl = memo(
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading || fakeLoading}
onStreamingChange={(streaming) => {
streamingState.set(streaming);
}}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
@@ -627,6 +351,8 @@ export const ChatImpl = memo(
provider={provider}
setProvider={handleProviderChange}
providerList={activeProviders}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={(e) => {
onTextareaChange(e);
debouncedCachePrompt(e);
@@ -661,24 +387,6 @@ export const ChatImpl = memo(
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
supabaseAlert={supabaseAlert}
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
deployAlert={deployAlert}
clearDeployAlert={() => workbenchStore.clearDeployAlert()}
llmErrorAlert={llmErrorAlert}
clearLlmErrorAlert={clearApiErrorAlert}
data={chatData}
chatMode={chatMode}
setChatMode={setChatMode}
append={append}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
addToolResult={addToolResult}
onWebSearchResult={handleWebSearchResult}
/>
);
},

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