Merge pull request #1245 from Stijnus/FEAT_BoltDYI_NEW_SETTINGS_UI_V3
Some checks failed
Docker Publish / docker-build-publish (push) Waiting to run
Update Stable Branch / prepare-release (push) Waiting to run
Docs CI/CD / build_docs (push) Has been cancelled

feat: bolt dyi new settings UI V3
This commit is contained in:
Cole Medin
2025-02-10 15:44:25 -06:00
committed by GitHub
148 changed files with 22607 additions and 5609 deletions

157
.cursorrules Normal file
View File

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

View File

@@ -1,4 +1,4 @@
name: "Bug report"
name: 'Bug report'
description: Create a report to help us improve
body:
- type: markdown

View File

@@ -19,5 +19,5 @@ 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.
<!-- 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

@@ -13,13 +13,13 @@ assignees: ''
# Scope
<!-- This is kind-of the definition-of-done for a feature.
<!-- 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.
<!-- 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.
-->

View File

@@ -8,7 +8,7 @@ on:
- main
tags:
- v*
- "*"
- '*'
permissions:
packages: write
@@ -57,7 +57,7 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'docs/**' # This will only trigger the workflow when files in docs directory change
- 'docs/**' # This will only trigger the workflow when files in docs directory change
permissions:
contents: write
jobs:
@@ -23,7 +23,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 +32,4 @@ jobs:
mkdocs-material-
- run: pip install mkdocs-material
- run: mkdocs gh-deploy --force
- run: mkdocs gh-deploy --force

View File

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

View File

@@ -29,4 +29,4 @@ jobs:
docs
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

@@ -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:
@@ -80,7 +80,6 @@ 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
@@ -89,14 +88,14 @@ jobs:
env:
NEW_VERSION: ${{ steps.bump_version.outputs.new_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/scripts/generate-changelog.sh
- 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
@@ -123,4 +122,4 @@ jobs:
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.content }}" \
--target stable
--target stable

7
.gitignore vendored
View File

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

View File

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

24
FAQ.md
View File

@@ -12,6 +12,7 @@ For the best experience with bolt.diy, we recommend using the following models:
- **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements
**Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt!
</details>
<details>
@@ -21,20 +22,21 @@ For the best experience with bolt.diy, we recommend using the following models:
Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences.
- **Use the enhance prompt icon**:
Before sending your prompt, click the *enhance* icon to let the AI refine your prompt. You can edit the suggested improvements before submitting.
Before sending your prompt, click the _enhance_ icon to let the AI refine your prompt. You can edit the suggested improvements before submitting.
- **Scaffold the basics first, then add features**:
Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on.
- **Batch simple instructions**:
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."*
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>
<details>
<summary><strong>How do I contribute to bolt.diy?</strong></summary>
Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved!
</details>
<details>
@@ -42,48 +44,60 @@ Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to g
Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates.
New features and improvements are on the way!
</details>
<details>
<summary><strong>Why are there so many open issues/pull requests?</strong></summary>
bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort!
bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort!
We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive.
</details>
<details>
<summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary>
While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs.
</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).
- 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>
---

View File

@@ -31,7 +31,7 @@ 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.
By linking features with epics, we can keep them together and document _why_ we invest work into a particular thing.
## Features (mid-term)
@@ -41,13 +41,13 @@ 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.
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*.
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.
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 🤓

View File

@@ -4,10 +4,12 @@
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
-----
---
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
-----
---
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
// 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(() => {
// Force enable these features by default
enableLatestBranch(true);
enableContextOptimization(true);
setAutoSelectTemplate(true);
setPromptId('optimized');
// Only enable event logs if not explicitly set before
if (eventLogs === undefined) {
setEventLogs(true);
}
}, []); // Only run once on component mount
const handleToggleFeature = useCallback(
(id: string, enabled: boolean) => {
switch (id) {
case 'latestBranch': {
enableLatestBranch(enabled);
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'autoSelectTemplate': {
setAutoSelectTemplate(enabled);
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'contextOptimization': {
enableContextOptimization(enabled);
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'eventLogs': {
setEventLogs(enabled);
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
break;
}
default:
break;
}
},
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
);
const features = {
stable: [
{
id: 'latestBranch',
title: 'Main Branch Updates',
description: 'Get the latest updates from the main branch',
icon: 'i-ph:git-branch',
enabled: isLatestBranch,
tooltip: 'Enabled by default to receive updates from the main development branch',
},
{
id: 'autoSelectTemplate',
title: 'Auto Select Template',
description: 'Automatically select starter template',
icon: 'i-ph:selection',
enabled: autoSelectTemplate,
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
},
{
id: 'contextOptimization',
title: 'Context Optimization',
description: 'Optimize context for better responses',
icon: 'i-ph:brain',
enabled: contextOptimizationEnabled,
tooltip: 'Enabled by default for improved AI responses',
},
{
id: 'eventLogs',
title: 'Event Logging',
description: 'Enable detailed event logging and history',
icon: 'i-ph:list-bullets',
enabled: eventLogs,
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
},
],
beta: [],
};
return (
<div className="flex flex-col gap-8">
<FeatureSection
title="Core Features"
features={features.stable}
icon="i-ph:check-circle"
description="Essential features that are enabled by default for optimal performance"
onToggleFeature={handleToggleFeature}
/>
{features.beta.length > 0 && (
<FeatureSection
title="Beta Features"
features={features.beta}
icon="i-ph:test-tube"
description="New features that are ready for testing but may have some rough edges"
onToggleFeature={handleToggleFeature}
/>
)}
<motion.div
layout
className={classNames(
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'rounded-lg p-4',
'group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center gap-4">
<div
className={classNames(
'p-2 rounded-lg text-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-colors duration-200',
'text-purple-500',
)}
>
<div className="i-ph:book" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
Prompt Library
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
Choose a prompt from the library to use as the system prompt
</p>
</div>
<select
value={promptId}
onChange={(e) => {
setPromptId(e.target.value);
toast.success('Prompt template updated');
}}
className={classNames(
'p-2 rounded-lg text-sm min-w-[200px]',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'group-hover:border-purple-500/30',
'transition-all duration-200',
)}
>
{PromptLibrary.getList().map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</div>
</motion.div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,174 @@
import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { profileStore, updateProfile } from '~/lib/stores/profile';
import { toast } from 'react-toastify';
export default function ProfileTab() {
const profile = useStore(profileStore);
const [isUploading, setIsUploading] = useState(false);
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) => {
updateProfile({ [field]: value });
// Only show toast for completed typing (after 1 second of no typing)
const debounceToast = setTimeout(() => {
toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
}, 1000);
return () => clearTimeout(debounceToast);
};
return (
<div className="max-w-2xl mx-auto">
<div className="space-y-6">
{/* Personal Information Section */}
<div>
{/* Avatar Upload */}
<div className="flex items-start gap-6 mb-8">
<div
className={classNames(
'w-24 h-24 rounded-full overflow-hidden',
'bg-gray-100 dark:bg-gray-800/50',
'flex items-center justify-center',
'ring-1 ring-gray-200 dark:ring-gray-700',
'relative group',
'transition-all duration-300 ease-out',
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
'hover:shadow-lg hover:shadow-purple-500/10',
)}
>
{profile.avatar ? (
<img
src={profile.avatar}
alt="Profile"
className={classNames(
'w-full h-full object-cover',
'transition-all duration-300 ease-out',
'group-hover:scale-105 group-hover:brightness-90',
)}
/>
) : (
<div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
)}
<label
className={classNames(
'absolute inset-0',
'flex items-center justify-center',
'bg-black/0 group-hover:bg-black/40',
'cursor-pointer transition-all duration-300 ease-out',
isUploading ? 'cursor-wait' : '',
)}
>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isUploading}
/>
{isUploading ? (
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
) : (
<div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
)}
</label>
</div>
<div className="flex-1 pt-1">
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
Profile Picture
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
</div>
</div>
{/* Username Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
<div className="relative group">
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
<div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<input
type="text"
value={profile.username}
onChange={(e) => handleProfileUpdate('username', e.target.value)}
className={classNames(
'w-full pl-11 pr-4 py-2.5 rounded-xl',
'bg-white dark:bg-gray-800/50',
'border border-gray-200 dark:border-gray-700/50',
'text-gray-900 dark:text-white',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
'transition-all duration-300 ease-out',
)}
placeholder="Enter your username"
/>
</div>
</div>
{/* Bio Input */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
<div className="relative group">
<div className="absolute left-3.5 top-3">
<div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<textarea
value={profile.bio}
onChange={(e) => handleProfileUpdate('bio', e.target.value)}
className={classNames(
'w-full pl-11 pr-4 py-2.5 rounded-xl',
'bg-white dark:bg-gray-800/50',
'border border-gray-200 dark:border-gray-700/50',
'text-gray-900 dark:text-white',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
'transition-all duration-300 ease-out',
'resize-none',
'h-32',
)}
placeholder="Tell us about yourself"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,310 @@
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 { themeStore, kTheme } from '~/lib/stores/theme';
import type { UserProfile } from '~/components/@settings/core/types';
import { useStore } from '@nanostores/react';
import { shortcutsStore } from '~/lib/stores/settings';
import { isMac } from '~/utils/os';
// Helper to format shortcut key display
const formatShortcutKey = (key: string) => {
if (key === '`') {
return '`';
}
return key.toUpperCase();
};
// 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 'ctrl':
return isMac ? '⌃' : 'Ctrl';
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)
: {
theme: 'system',
notifications: true,
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
});
useEffect(() => {
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
// Apply theme when settings changes
useEffect(() => {
if (settings.theme === 'system') {
// Remove theme override
localStorage.removeItem(kTheme);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
themeStore.set(prefersDark ? 'dark' : 'light');
} else {
themeStore.set(settings.theme);
localStorage.setItem(kTheme, settings.theme);
document.querySelector('html')?.setAttribute('data-theme', settings.theme);
}
}, [settings.theme]);
// 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,
theme: settings.theme,
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">
{/* Theme & Language */}
<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">Appearance</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:paint-brush-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Theme</label>
</div>
<div className="flex gap-2">
{(['light', 'dark', 'system'] as const).map((theme) => (
<button
key={theme}
onClick={() => {
setSettings((prev) => ({ ...prev, theme }));
if (theme !== 'system') {
themeStore.set(theme);
}
}}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed',
settings.theme === theme
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:text-white dark:hover:bg-purple-600'
: 'bg-bolt-elements-hover dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-purple-500/10 hover:text-purple-500 dark:hover:bg-purple-500/20 dark:text-bolt-elements-textPrimary dark:hover:text-purple-500',
)}
>
<div
className={`w-4 h-4 ${
theme === 'light'
? 'i-ph:sun-fill'
: theme === 'dark'
? 'i-ph:moon-stars-fill'
: 'i-ph:monitor-fill'
}`}
/>
<span className="capitalize">{theme}</span>
</button>
))}
</div>
</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>
{/* 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">
{Object.entries(useStore(shortcutsStore)).map(([name, shortcut]) => (
<div
key={name}
className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A] hover:bg-purple-50 dark:hover:bg-purple-500/10 transition-colors"
>
<div className="flex flex-col">
<span className="text-sm text-bolt-elements-textPrimary capitalize">
{name.replace(/([A-Z])/g, ' $1').toLowerCase()}
</span>
{shortcut.description && (
<span className="text-xs text-bolt-elements-textSecondary">{shortcut.description}</span>
)}
</div>
<div className="flex items-center gap-1">
{shortcut.ctrlOrMetaKey && (
<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(isMac ? 'meta' : 'ctrl')}
</kbd>
)}
{shortcut.ctrlKey && (
<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('ctrl')}
</kbd>
)}
{shortcut.metaKey && (
<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>
)}
{shortcut.altKey && (
<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>
)}
{shortcut.shiftKey && (
<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">
{formatShortcutKey(shortcut.key)}
</kbd>
</div>
</div>
))}
</div>
</motion.div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -586,8 +586,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<div className="flex flex-col justify-center gap-5">
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
<div className="flex items-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
</div>
</div>
)}
{!chatStarted &&

View File

@@ -23,6 +23,7 @@ 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';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -114,8 +115,8 @@ export const ChatImpl = memo(
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
@@ -161,6 +162,11 @@ export const ChatImpl = memo(
sendExtraMessageFields: true,
onError: (e) => {
logger.error('Request failed\n\n', e, error);
logStore.logError('Chat request failed', e, {
component: 'Chat',
action: 'request',
error: e.message,
});
toast.error(
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
);
@@ -171,8 +177,14 @@ export const ChatImpl = memo(
if (usage) {
console.log('Token usage:', usage);
// You can now use the usage data as needed
logStore.logProvider('Chat response completed', {
component: 'Chat',
action: 'response',
model,
provider: provider.name,
usage,
messageLength: message.content.length,
});
}
logger.debug('Finished streaming');
@@ -231,6 +243,13 @@ export const ChatImpl = memo(
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
logStore.logProvider('Chat response aborted', {
component: 'Chat',
action: 'abort',
model,
provider: provider.name,
});
};
useEffect(() => {
@@ -262,20 +281,90 @@ export const ChatImpl = memo(
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const _input = messageInput || input;
const messageContent = messageInput || input;
if (_input.length === 0 || isLoading) {
if (!messageContent?.trim()) {
return;
}
/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* of some kind so the user is aware that something is happening. But I consider the
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();
if (isLoading) {
abort();
return;
}
runAnimation();
if (!chatStarted) {
setFakeLoading(true);
if (autoSelectTemplate) {
const { template, title } = await selectStarterTemplate({
message: messageContent,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title).catch((e) => {
if (e.message.includes('rate limit')) {
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
} else {
toast.warning('Failed to import starter template\n Continuing with blank template');
}
return null;
});
if (temResp) {
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: messageContent,
},
{
id: `${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setFakeLoading(false);
return;
}
}
}
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
},
]);
reload();
setFakeLoading(false);
return;
}
if (error != null) {
setMessages(messages.slice(0, -1));
@@ -285,146 +374,21 @@ export const ChatImpl = memo(
chatStore.setKey('aborted', false);
runAnimation();
if (!chatStarted && _input && autoSelectTemplate) {
setFakeLoading(true);
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
},
]);
// reload();
const { template, title } = await selectStarterTemplate({
message: _input,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title).catch((e) => {
if (e.message.includes('rate limit')) {
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
} else {
toast.warning('Failed to import starter template\n Continuing with blank template');
}
return null;
});
if (temResp) {
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: _input,
// annotations: ['hidden'],
},
{
id: `${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setFakeLoading(false);
return;
} else {
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
},
]);
reload();
setFakeLoading(false);
return;
}
} else {
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
},
]);
reload();
setFakeLoading(false);
return;
}
}
if (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}`,
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
] as any,
});
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
append({
@@ -432,20 +396,19 @@ export const ChatImpl = memo(
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any, // Type assertion to bypass compiler check
] as any,
});
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
// Add file cleanup here
setUploadedFiles([]);
setImageDataList([]);

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import type { Message } from 'ai';
import React, { Fragment } from 'react';
import React, { Fragment, useEffect, useRef, useState } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
@@ -8,6 +8,8 @@ import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
interface MessagesProps {
id?: string;
@@ -19,6 +21,95 @@ interface MessagesProps {
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const [lastScrollTop, setLastScrollTop] = useState(0);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const profile = useStore(profileStore);
// Check if we should auto-scroll based on scroll position
const checkShouldAutoScroll = () => {
if (!containerRef.current) {
return true;
}
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
return distanceFromBottom < 100;
};
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (!shouldAutoScroll || isUserInteracting) {
return;
}
messagesEndRef.current?.scrollIntoView({ behavior });
};
// Handle user interaction and scroll position
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const handleInteractionStart = () => {
setIsUserInteracting(true);
};
const handleInteractionEnd = () => {
if (checkShouldAutoScroll()) {
setTimeout(() => setIsUserInteracting(false), 100);
}
};
const handleScroll = () => {
const { scrollTop } = container;
const shouldScroll = checkShouldAutoScroll();
// Update auto-scroll state based on scroll position
setShouldAutoScroll(shouldScroll);
// If scrolling up, disable auto-scroll
if (scrollTop < lastScrollTop) {
setIsUserInteracting(true);
}
setLastScrollTop(scrollTop);
};
container.addEventListener('mousedown', handleInteractionStart);
container.addEventListener('mouseup', handleInteractionEnd);
container.addEventListener('touchstart', handleInteractionStart);
container.addEventListener('touchend', handleInteractionEnd);
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('mousedown', handleInteractionStart);
container.removeEventListener('mouseup', handleInteractionEnd);
container.removeEventListener('touchstart', handleInteractionStart);
container.removeEventListener('touchend', handleInteractionEnd);
container.removeEventListener('scroll', handleScroll);
};
}, [lastScrollTop]);
// Scroll to bottom when new messages are added or during streaming
useEffect(() => {
if (messages.length > 0 && (isStreaming || shouldAutoScroll)) {
scrollToBottom('smooth');
}
}, [messages, isStreaming, shouldAutoScroll]);
// Initial scroll on component mount
useEffect(() => {
if (messages.length > 0) {
scrollToBottom('instant');
setShouldAutoScroll(true);
}
}, []);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -41,7 +132,20 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
};
return (
<div id={id} ref={ref} className={props.className}>
<div
id={id}
ref={(el) => {
// Combine refs
if (typeof ref === 'function') {
ref(el);
}
(containerRef as any).current = el;
return undefined;
}}
className={props.className}
>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId, annotations } = message;
@@ -65,8 +169,18 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
<div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'User'}
className="w-full h-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="i-ph:user-fill text-2xl" />
)}
</div>
)}
<div className="grid grid-col-1 w-full">
@@ -107,6 +221,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
);
})
: null}
<div ref={messagesEndRef} /> {/* Add an empty div as scroll anchor */}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}

View File

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

View File

@@ -1,6 +1,8 @@
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
type ChatData = {
messages?: Message[]; // Standard Bolt format
@@ -57,19 +59,35 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
/>
<div className="flex flex-col items-center gap-4 max-w-2xl text-center">
<div className="flex gap-2">
<button
<Button
onClick={() => {
const input = document.getElementById('chat-import');
input?.click();
}}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
variant="outline"
size="lg"
className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out',
)}
>
<div className="i-ph:upload-simple" />
<span className="i-ph:upload-simple w-4 h-4" />
Import Chat
</button>
</Button>
<ImportFolderButton
importChat={importChat}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
'text-bolt-elements-textPrimary dark:text-white',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
'border border-[#E5E5E5] dark:border-[#333333]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out rounded-lg',
)}
/>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { classNames } from '~/utils/classNames';
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(({ className, value, ...props }, ref) => (
<div
ref={ref}
className={classNames('relative h-2 w-full overflow-hidden rounded-full bg-bolt-elements-background', className)}
{...props}
>
<div
className="h-full w-full flex-1 bg-bolt-elements-textPrimary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
));
Progress.displayName = 'Progress';
export { Progress };

View File

@@ -0,0 +1,41 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { classNames } from '~/utils/classNames';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={classNames('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={classNames(
'flex touch-none select-none transition-colors',
{
'h-full w-2.5 border-l border-l-transparent p-[1px]': orientation === 'vertical',
'h-2.5 flex-col border-t border-t-transparent p-[1px]': orientation === 'horizontal',
},
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-bolt-elements-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,22 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { classNames } from '~/utils/classNames';
interface SeparatorProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
}
export const Separator = ({ className, orientation = 'horizontal' }: SeparatorProps) => {
return (
<SeparatorPrimitive.Root
className={classNames(
'bg-bolt-elements-borderColor',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
orientation={orientation}
/>
);
};
export default Separator;

View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { classNames } from '~/utils/classNames';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={classNames(
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background p-1 text-bolt-elements-textSecondary',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={classNames(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background data-[state=active]:text-bolt-elements-textPrimary data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={classNames(
'mt-2 ring-offset-bolt-elements-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { toast as toastify } from 'react-toastify';
interface ToastOptions {
type?: 'success' | 'error' | 'info' | 'warning';
duration?: number;
}
export function useToast() {
const toast = useCallback((message: string, options: ToastOptions = {}) => {
const { type = 'info', duration = 3000 } = options;
toastify[type](message, {
position: 'bottom-right',
autoClose: duration,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: 'dark',
});
}, []);
const success = useCallback(
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
toast(message, { ...options, type: 'success' });
},
[toast],
);
const error = useCallback(
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
toast(message, { ...options, type: 'error' });
},
[toast],
);
return { toast, success, error };
}

View File

@@ -87,7 +87,9 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
<DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild>
<span
ref={(ref) => (segmentRefs.current[index] = ref)}
ref={(ref) => {
segmentRefs.current[index] = ref;
}}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive,

View File

@@ -17,8 +17,7 @@ import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import Cookies from 'js-cookie';
import { chatMetadata, useChatHistory } from '~/lib/persistence';
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -59,6 +58,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
@@ -67,8 +67,6 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const metadata = useStore(chatMetadata);
const { updateChatMestaData } = useChatHistory();
const isSmallViewport = useViewport(1024);
@@ -171,65 +169,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
let repoName = metadata?.gitUrl?.split('/').slice(-1)[0]?.replace('.git', '') || null;
let repoConfirmed: boolean = true;
if (repoName) {
repoConfirmed = confirm(`Do you want to push to the repository ${repoName}?`);
}
if (!repoName || !repoConfirmed) {
repoName = prompt(
'Please enter a name for your new GitHub repository:',
'bolt-generated-project',
);
} else {
}
if (!repoName) {
alert('Repository name is required. Push to GitHub cancelled.');
return;
}
let githubUsername = Cookies.get('githubUsername');
let githubToken = Cookies.get('githubToken');
if (!githubUsername || !githubToken) {
const usernameInput = prompt('Please enter your GitHub username:');
const tokenInput = prompt('Please enter your GitHub personal access token:');
if (!usernameInput || !tokenInput) {
alert('GitHub username and token are required. Push to GitHub cancelled.');
return;
}
githubUsername = usernameInput;
githubToken = tokenInput;
Cookies.set('githubUsername', usernameInput);
Cookies.set('githubToken', tokenInput);
Cookies.set(
'git:github.com',
JSON.stringify({ username: tokenInput, password: 'x-oauth-basic' }),
);
}
const commitMessage =
prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
workbenchStore.pushToGitHub(repoName, commitMessage, githubUsername, githubToken);
if (!metadata?.gitUrl) {
updateChatMestaData({
...(metadata || {}),
gitUrl: `https://github.com/${githubUsername}/${repoName}.git`,
});
}
}}
>
<div className="i-ph:github-logo" />
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
<div className="i-ph:git-branch" />
Push to GitHub
</PanelHeaderButton>
</div>
@@ -271,10 +212,26 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
</div>
</div>
</div>
<PushToGitHubDialog
isOpen={isPushDialogOpen}
onClose={() => setIsPushDialogOpen(false)}
onPush={async (repoName, username, token, isPrivate) => {
try {
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
return repoUrl;
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub');
throw error; // Rethrow to let PushToGitHubDialog handle the error state
}
}}
/>
</motion.div>
)
);
});
// View component for rendering content with motion transitions
interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}

View File

@@ -127,7 +127,7 @@ export async function selectContext(props: {
---
${filePaths.map((path) => `- ${path}`).join('\n')}
---
You have following code loaded in the context buffer that you can refer to:
CURRENT CONTEXT BUFFER
@@ -138,14 +138,14 @@ export async function selectContext(props: {
Now, you are given a task. You need to select the files that are relevant to the task from the list of files above.
RESPONSE FORMAT:
your response shoudl be in following format:
your response should be in following format:
---
<updateContextBuffer>
<includeFile path="path/to/file"/>
<excludeFile path="path/to/file"/>
</updateContextBuffer>
---
* Your should start with <updateContextBuffer> and end with </updateContextBuffer>.
* Your should start with <updateContextBuffer> and end with </updateContextBuffer>.
* You can include multiple <includeFile> and <excludeFile> tags in the response.
* You should not include any other text in the response.
* You should not include any file that is not in the list of files above.

63
app/lib/api/connection.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface ConnectionStatus {
connected: boolean;
latency: number;
lastChecked: string;
}
export const checkConnection = async (): Promise<ConnectionStatus> => {
try {
// Check if we have network connectivity
const online = navigator.onLine;
if (!online) {
return {
connected: false,
latency: 0,
lastChecked: new Date().toISOString(),
};
}
// Try multiple endpoints in case one fails
const endpoints = [
'/api/health',
'/', // Fallback to root route
'/favicon.ico', // Another common fallback
];
let latency = 0;
let connected = false;
for (const endpoint of endpoints) {
try {
const start = performance.now();
const response = await fetch(endpoint, {
method: 'HEAD',
cache: 'no-cache',
});
const end = performance.now();
if (response.ok) {
latency = Math.round(end - start);
connected = true;
break;
}
} catch (endpointError) {
console.debug(`Failed to connect to ${endpoint}:`, endpointError);
continue;
}
}
return {
connected,
latency,
lastChecked: new Date().toISOString(),
};
} catch (error) {
console.error('Connection check failed:', error);
return {
connected: false,
latency: 0,
lastChecked: new Date().toISOString(),
};
}
};

121
app/lib/api/debug.ts Normal file
View File

@@ -0,0 +1,121 @@
export interface DebugWarning {
id: string;
message: string;
timestamp: string;
code: string;
}
export interface DebugError {
id: string;
message: string;
timestamp: string;
stack?: string;
}
export interface DebugStatus {
warnings: DebugIssue[];
errors: DebugIssue[];
}
export interface DebugIssue {
id: string;
message: string;
type: 'warning' | 'error';
timestamp: string;
details?: Record<string, unknown>;
}
// Keep track of acknowledged issues
const acknowledgedIssues = new Set<string>();
export const getDebugStatus = async (): Promise<DebugStatus> => {
const issues: DebugStatus = {
warnings: [],
errors: [],
};
try {
// Check memory usage
if (performance && 'memory' in performance) {
const memory = (performance as any).memory;
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.8) {
issues.warnings.push({
id: 'high-memory-usage',
message: 'High memory usage detected',
type: 'warning',
timestamp: new Date().toISOString(),
details: {
used: memory.usedJSHeapSize,
total: memory.jsHeapSizeLimit,
},
});
}
}
// Check storage quota
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const usageRatio = (estimate.usage || 0) / (estimate.quota || 1);
if (usageRatio > 0.9) {
issues.warnings.push({
id: 'storage-quota-warning',
message: 'Storage quota nearly reached',
type: 'warning',
timestamp: new Date().toISOString(),
details: {
used: estimate.usage,
quota: estimate.quota,
},
});
}
}
// Check for console errors (if any)
const errorLogs = localStorage.getItem('error_logs');
if (errorLogs) {
const errors = JSON.parse(errorLogs);
errors.forEach((error: any) => {
issues.errors.push({
id: `error-${error.timestamp}`,
message: error.message,
type: 'error',
timestamp: error.timestamp,
details: error.details,
});
});
}
// Filter out acknowledged issues
issues.warnings = issues.warnings.filter((warning) => !acknowledgedIssues.has(warning.id));
issues.errors = issues.errors.filter((error) => !acknowledgedIssues.has(error.id));
return issues;
} catch (error) {
console.error('Error getting debug status:', error);
return issues;
}
};
export const acknowledgeWarning = async (id: string): Promise<void> => {
acknowledgedIssues.add(id);
};
export const acknowledgeError = async (id: string): Promise<void> => {
acknowledgedIssues.add(id);
// Also remove from error logs if present
try {
const errorLogs = localStorage.getItem('error_logs');
if (errorLogs) {
const errors = JSON.parse(errorLogs);
const updatedErrors = errors.filter((error: any) => `error-${error.timestamp}` !== id);
localStorage.setItem('error_logs', JSON.stringify(updatedErrors));
}
} catch (error) {
console.error('Error acknowledging error:', error);
}
};

35
app/lib/api/features.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface Feature {
id: string;
name: string;
description: string;
viewed: boolean;
releaseDate: string;
}
export const getFeatureFlags = async (): Promise<Feature[]> => {
/*
* TODO: Implement actual feature flags logic
* This is a mock implementation
*/
return [
{
id: 'feature-1',
name: 'Dark Mode',
description: 'Enable dark mode for better night viewing',
viewed: true,
releaseDate: '2024-03-15',
},
{
id: 'feature-2',
name: 'Tab Management',
description: 'Customize your tab layout',
viewed: false,
releaseDate: '2024-03-20',
},
];
};
export const markFeatureViewed = async (featureId: string): Promise<void> => {
/* TODO: Implement actual feature viewed logic */
console.log(`Marking feature ${featureId} as viewed`);
};

View File

@@ -0,0 +1,58 @@
import { logStore } from '~/lib/stores/logs';
import type { LogEntry } from '~/lib/stores/logs';
export interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'warning' | 'error' | 'success';
timestamp: string;
read: boolean;
details?: Record<string, unknown>;
}
export interface LogEntryWithRead extends LogEntry {
read: boolean;
}
export const getNotifications = async (): Promise<Notification[]> => {
// Get notifications from the log store
const logs = Object.values(logStore.logs.get());
return logs
.filter((log) => log.category !== 'system') // Filter out system logs
.map((log) => ({
id: log.id,
title: (log.details?.title as string) || log.message.split('\n')[0],
message: log.message,
type: log.level as 'info' | 'warning' | 'error' | 'success',
timestamp: log.timestamp,
read: logStore.isRead(log.id),
details: log.details,
}))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
};
export const markNotificationRead = async (notificationId: string): Promise<void> => {
logStore.markAsRead(notificationId);
};
export const clearNotifications = async (): Promise<void> => {
logStore.clearLogs();
};
export const getUnreadCount = (): number => {
const logs = Object.values(logStore.logs.get()) as LogEntryWithRead[];
return logs.filter((log) => {
if (!logStore.isRead(log.id)) {
if (log.details?.type === 'update') {
return true;
}
return log.level === 'error' || log.level === 'warning';
}
return false;
}).length;
};

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