V1 : Release of the new Settings Dashboard

# 🚀 Release v1.0.0

## What's Changed 🌟

### 🎨 UI/UX Improvements
- **Dark Mode Support**
  - Implemented comprehensive dark theme across all components
  - Enhanced contrast and readability in dark mode
  - Added smooth theme transitions
  - Optimized dialog overlays and backdrops

### 🛠️ Settings Panel
- **Data Management**
  - Added chat history export/import functionality
  - Implemented settings backup and restore
  - Added secure data deletion with confirmations
  - Added profile customization options

- **Provider Management**
  - Added comprehensive provider configuration
  - Implemented URL-configurable providers
  - Added local model support (Ollama, LMStudio)
  - Added provider health checks
  - Added provider status indicators

- **Ollama Integration**
  - Added Ollama Model Manager with real-time updates
  - Implemented model version tracking
  - Added bulk update capability
  - Added progress tracking for model updates
  - Displays model details (parameter size, quantization)

- **GitHub Integration**
  - Added GitHub connection management
  - Implemented secure token storage
  - Added connection state persistence
  - Real-time connection status updates
  - Proper error handling and user feedback

### 📊 Event Logging
- **System Monitoring**
  - Added real-time event logging system
  - Implemented log filtering by type (info, warning, error, debug)
  - Added log export functionality
  - Added auto-scroll and search capabilities
  - Enhanced log visualization with color coding

### 💫 Animations & Interactions
- Added smooth page transitions
- Implemented loading states with spinners
- Added micro-interactions for better feedback
- Enhanced button hover and active states
- Added motion effects for UI elements

### 🔐 Security Features
- Secure token storage
- Added confirmation dialogs for destructive actions
- Implemented data validation
- Added file size and type validation
- Secure connection management

### ️ Accessibility
- Improved keyboard navigation
- Enhanced screen reader support
- Added ARIA labels and descriptions
- Implemented focus management
- Added proper dialog accessibility

### 🎯 Developer Experience
- Added comprehensive debug information
- Implemented system status monitoring
- Added version control integration
- Enhanced error handling and reporting
- Added detailed logging system

---

## 🔧 Technical Details
- **Frontend Stack**
  - React 18 with TypeScript
  - Framer Motion for animations
  - TailwindCSS for styling
  - Radix UI for accessible components

- **State Management**
  - Local storage for persistence
  - React hooks for state
  - Custom stores for global state

- **API Integration**
  - GitHub API integration
  - Ollama API integration
  - Provider API management
  - Error boundary implementation

## 📝 Notes
- Initial release focusing on core functionality and user experience
- Enhanced dark mode support across all components
- Improved accessibility and keyboard navigation
- Added comprehensive logging and debugging tools
- Implemented robust error handling and user feedback
This commit is contained in:
Stijnus 2025-01-17 19:33:20 +01:00
parent 41bb909f8d
commit f33ba635e8
20 changed files with 3134 additions and 1044 deletions

39
.windsurf/config.json Normal file
View File

@ -0,0 +1,39 @@
{
"enabled": true,
"rulesPath": ".windsurf/rules.json",
"integration": {
"ide": {
"cursor": true,
"vscode": true
},
"autoApply": true,
"notifications": true,
"autoFix": {
"enabled": true,
"onSave": true,
"formatOnSave": true,
"suggestImports": true,
"suggestComponents": true
},
"suggestions": {
"inline": true,
"quickFix": true,
"codeActions": true,
"snippets": true
}
},
"features": {
"codeCompletion": true,
"linting": true,
"formatting": true,
"importValidation": true,
"dependencyChecks": true,
"uiStandardization": true
},
"hooks": {
"preCommit": true,
"prePush": true,
"onFileCreate": true,
"onImportAdd": true
}
}

103
.windsurf/rules.json Normal file
View File

@ -0,0 +1,103 @@
{
"version": "1.0",
"rules": {
"fileTypes": {
"typescript": ["ts", "tsx"],
"javascript": ["js", "jsx", "mjs", "cjs"],
"json": ["json"],
"markdown": ["md"],
"css": ["css"],
"dockerfile": ["Dockerfile"]
},
"formatting": {
"typescript": {
"indentSize": 2,
"useTabs": false,
"maxLineLength": 100,
"semicolons": true,
"quotes": "single",
"trailingComma": "es5"
},
"javascript": {
"indentSize": 2,
"useTabs": false,
"maxLineLength": 100,
"semicolons": true,
"quotes": "single",
"trailingComma": "es5"
}
},
"linting": {
"typescript": {
"noUnusedVariables": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noConsole": "warn"
}
},
"dependencies": {
"nodeVersion": ">=18.18.0",
"packageManager": "pnpm",
"requiredFiles": ["package.json", "tsconfig.json", ".env.example"]
},
"git": {
"ignoredPaths": ["node_modules", "build", ".env", ".env.local"],
"protectedBranches": ["main", "master"]
},
"testing": {
"framework": "vitest",
"coverage": {
"statements": 70,
"branches": 70,
"functions": 70,
"lines": 70
}
},
"security": {
"secrets": {
"patterns": ["API_KEY", "SECRET", "PASSWORD", "TOKEN"],
"locations": [".env", ".env.local"]
}
},
"commands": {
"dev": "pnpm dev",
"build": "pnpm build",
"test": "pnpm test",
"lint": "pnpm lint",
"typecheck": "pnpm typecheck"
},
"codeQuality": {
"imports": {
"validateImports": true,
"checkPackageAvailability": true,
"requireExactVersions": true,
"preventUnusedImports": true
},
"fileManagement": {
"preventUnnecessaryFiles": true,
"requireFileJustification": true,
"checkExistingImplementations": true
},
"dependencies": {
"autoInstallMissing": false,
"validateVersionCompatibility": true,
"checkPackageJson": true
}
},
"uiStandards": {
"styling": {
"framework": "tailwind",
"preferredIconSets": ["@iconify-json/ph", "@iconify-json/svg-spinners"],
"colorScheme": {
"useSystemPreference": true,
"supportDarkMode": true
},
"components": {
"preferModern": true,
"accessibility": true,
"responsive": true
}
}
}
}
}

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,10 +1,11 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState, type ReactElement } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState } 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 { DialogTitle } from '~/components/ui/Dialog';
import type { SettingCategory, TabType } from './settings.types';
import { categoryLabels, categoryIcons } from './settings.types';
import ProfileTab from './profile/ProfileTab';
import ProvidersTab from './providers/ProvidersTab';
import { useSettings } from '~/lib/hooks/useSettings';
import FeaturesTab from './features/FeaturesTab';
@ -18,110 +19,281 @@ interface SettingsProps {
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 [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<TabType | null>(null);
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 />,
},
]
: []),
];
const settingItems = [
{
id: 'profile' as const,
label: 'Profile Settings',
icon: 'i-ph:user-circle',
category: 'profile' as const,
description: 'Manage your personal information and preferences',
component: () => <ProfileTab />,
keywords: ['profile', 'account', 'avatar', 'email', 'name', 'theme', 'notifications'],
},
{
id: 'data' as const,
label: 'Data Management',
icon: 'i-ph:database',
category: 'file_sharing' as const,
description: 'Manage your chat history and application data',
component: () => <DataTab />,
keywords: ['data', 'export', 'import', 'backup', 'delete'],
},
{
id: 'providers' as const,
label: 'Providers',
icon: 'i-ph:key',
category: 'file_sharing' as const,
description: 'Configure AI providers and API keys',
component: () => <ProvidersTab />,
keywords: ['api', 'keys', 'providers', 'configuration'],
},
{
id: 'connection' as const,
label: 'Connection',
icon: 'i-ph:link',
category: 'connectivity' as const,
description: 'Manage network and connection settings',
component: () => <ConnectionsTab />,
keywords: ['network', 'connection', 'proxy', 'ssl'],
},
{
id: 'features' as const,
label: 'Features',
icon: 'i-ph:star',
category: 'system' as const,
description: 'Configure application features and preferences',
component: () => <FeaturesTab />,
keywords: ['features', 'settings', 'options'],
},
] as const;
const debugItems = debug
? [
{
id: 'debug' as const,
label: 'Debug',
icon: 'i-ph:bug',
category: 'system' as const,
description: 'Advanced debugging tools and options',
component: () => <DebugTab />,
keywords: ['debug', 'logs', 'developer'],
},
]
: [];
const eventLogItems = eventLogs
? [
{
id: 'event-logs' as const,
label: 'Event Logs',
icon: 'i-ph:list-bullets',
category: 'system' as const,
description: 'View system events and application logs',
component: () => <EventLogsTab />,
keywords: ['logs', 'events', 'history'],
},
]
: [];
const allSettingItems = [...settingItems, ...debugItems, ...eventLogItems];
const filteredItems = allSettingItems.filter(
(item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.keywords?.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase())),
);
const groupedItems = filteredItems.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = allSettingItems.filter((i) => i.category === item.category);
}
return acc;
},
{} as Record<SettingCategory, typeof allSettingItems>,
);
const handleBackToDashboard = () => {
setActiveTab(null);
onClose();
};
const activeTabItem = allSettingItems.find((item) => item.id === activeTab);
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="fixed inset-0 flex items-center justify-center z-[9999]">
<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} asChild>
<motion.div
className={classNames(
'relative',
'w-[1000px] max-h-[90vh] min-h-[700px]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl overflow-hidden shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent',
)}
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 }}
>
<AnimatePresence mode="wait">
{activeTab ? (
<motion.div
className="flex flex-col h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<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 items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
<div className="flex items-center">
<button
onClick={() => setActiveTab(null)}
className="flex items-center gap-2 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"
>
<div className="i-ph:arrow-left w-4 h-4" />
Back to Settings
</button>
<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>
<div className="text-bolt-elements-textTertiary mx-6 select-none">|</div>
{activeTabItem && (
<div className="flex items-center gap-4">
<div className={classNames(activeTabItem.icon, 'w-6 h-6 text-purple-500')} />
<div>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">
{activeTabItem.label}
</h2>
<p className="text-sm text-bolt-elements-textSecondary">{activeTabItem.description}</p>
</div>
</div>
)}
</div>
<button
onClick={handleBackToDashboard}
className="flex items-center gap-2 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"
>
<div className="i-ph:house w-4 h-4" />
Back to Bolt DIY
</button>
</div>
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
{allSettingItems.find((item) => item.id === activeTab)?.component()}
</div>
</motion.div>
) : (
<motion.div
className="flex flex-col h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
<div className="flex items-center gap-3">
<div className="i-ph:lightning-fill w-5 h-5 text-purple-500" />
<DialogTitle className="text-lg font-medium text-bolt-elements-textPrimary">
Bolt Control Panel
</DialogTitle>
</div>
<div className="flex items-center gap-4">
<div className="relative w-[320px]">
<input
type="text"
placeholder="Search settings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-full h-10 pl-10 pr-4 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 transition-all',
)}
/>
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textTertiary" />
</div>
</div>
<button
onClick={handleBackToDashboard}
className="flex items-center gap-2 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"
>
<div className="i-ph:house w-4 h-4" />
Back to Bolt DIY
</button>
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
<div className="space-y-8">
{(Object.keys(groupedItems) as SettingCategory[]).map((category) => (
<div key={category} className="space-y-4">
<div className="flex items-center gap-3">
<div className={classNames(categoryIcons[category], 'w-5 h-5 text-purple-500')} />
<h2 className="text-base font-medium text-bolt-elements-textPrimary">
{categoryLabels[category]}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{groupedItems[category].map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={classNames(
'flex flex-col gap-2 p-4 rounded-lg text-left',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-[#F8F8F8] dark:hover:bg-[#1A1A1A]',
'transition-all duration-200',
)}
>
<div className="flex items-center gap-3">
<div className={classNames(item.icon, 'w-5 h-5 text-purple-500')} />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{item.label}
</span>
</div>
{item.description && (
<p className="text-sm text-bolt-elements-textSecondary">{item.description}</p>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
);

View File

@ -1,150 +1,207 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
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;
id: number;
[key: string]: any; // for other properties we don't explicitly need
avatar_url: string;
html_url: string;
}
interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
}
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);
const [connection, setConnection] = useState<GitHubConnection>({
user: null,
token: '',
});
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
// Load saved connection on mount
useEffect(() => {
// Check if credentials exist and verify them
if (githubUsername && githubToken) {
verifyGitHubCredentials();
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
setConnection(JSON.parse(savedConnection));
}
setIsLoading(false);
}, []);
const verifyGitHubCredentials = async () => {
setIsVerifying(true);
const fetchGithubUser = async (token: string) => {
try {
setIsConnecting(true);
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${githubToken}`,
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = (await response.json()) as GitHubUserResponse;
if (data.login === githubUsername) {
setIsConnected(true);
return true;
}
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
setIsConnected(false);
const data = (await response.json()) as GitHubUserResponse;
const newConnection = { user: data, token };
return false;
// Save connection
localStorage.setItem('github_connection', JSON.stringify(newConnection));
setConnection(newConnection);
toast.success('Successfully connected to GitHub');
} catch (error) {
console.error('Error verifying GitHub credentials:', error);
setIsConnected(false);
return false;
logStore.logError('Failed to authenticate with GitHub', { error });
toast.error('Failed to connect to GitHub');
setConnection({ user: null, token: '' });
} finally {
setIsVerifying(false);
setIsConnecting(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 handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
await fetchGithubUser(connection.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!');
localStorage.removeItem('github_connection');
setConnection({ user: null, token: '' });
toast.success('Disconnected from GitHub');
};
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"
/>
if (isLoading) {
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>
<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>
)}
);
}
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">GitHub Username</label>
<input
type="text"
value={connection.user?.login || ''}
disabled={true}
placeholder="Not connected"
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>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access 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 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>
</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-green-500 flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4" />
Connected to GitHub
</span>
)}
</div>
</div>
</motion.div>
</div>
</div>
);

View File

@ -1,388 +1,422 @@
import React, { useState } from 'react';
import { useNavigate } from '@remix-run/react';
import Cookies from 'js-cookie';
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
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;
}
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
import { db, getAll } from '~/lib/persistence';
export default function DataTab() {
const navigate = useNavigate();
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
const [isImportingKeys, setIsImportingKeys] = useState(false);
const [isResetting, setIsResetting] = useState(false);
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 [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
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 {
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(),
};
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
logStore.logSystem('Chats exported successfully', { count: allChats.length });
// 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) {
logStore.logError('Failed to export chats', error);
console.error('Export error:', 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.');
const handleExportSettings = () => {
try {
const settings = {
userProfile: localStorage.getItem('bolt_user_profile'),
settings: localStorage.getItem('bolt_settings'),
exportDate: new Date().toISOString(),
};
if (!confirmDelete) {
return;
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');
}
};
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');
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setIsDeleting(true);
const content = await file.text();
const settings = JSON.parse(content);
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 });
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) {
logStore.logError('Failed to delete chats', error);
toast.error('Failed to delete chats');
console.error(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
localStorage.removeItem('bolt_user_profile');
localStorage.removeItem('bolt_settings');
localStorage.removeItem('bolt_chat_history');
// Reload the page to apply reset
window.location.reload();
toast.success('Settings reset successfully');
} catch (error) {
console.error('Reset error:', error);
toast.error('Failed to reset settings');
} finally {
setIsResetting(false);
}
};
const handleDeleteAllChats = async () => {
setIsDeleting(true);
try {
// Clear chat history
localStorage.removeItem('bolt_chat_history');
toast.success('Chat history deleted successfully');
} catch (error) {
console.error('Delete error:', error);
toast.error('Failed to delete chat history');
} 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 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}>
<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>
<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
<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>
<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>
</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}>
<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">Chat History</h3>
</div>
</div>
<p className="text-sm text-bolt-elements-textSecondary 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">Settings Backup</h3>
</div>
<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">
<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"
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">API Keys Management</h3>
</div>
<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">
<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" />
) : (
<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>
);
}

View File

@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
interface ProviderStatus {
name: string;
@ -438,107 +441,182 @@ export default function DebugTab() {
}, [activeProviders, systemInfo, isLatestBranch]);
return (
<div className="p-4 space-y-6">
<div className="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 items-center gap-2">
<div className="i-ph:bug-fill text-xl text-purple-500" />
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
</div>
<div className="flex gap-2">
<button
<motion.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"
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:copy" />
Copy Debug Info
</button>
<button
</motion.button>
<motion.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`}
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
whileHover={!isCheckingUpdate ? { scale: 1.02 } : undefined}
whileTap={!isCheckingUpdate ? { scale: 0.98 } : undefined}
>
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
</button>
{isCheckingUpdate ? (
<>
<div className={settingsStyles['loading-spinner']} />
Checking...
</>
) : (
<>
<div className="i-ph:arrow-clockwise" />
Check for Updates
</>
)}
</motion.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>
<motion.div
className={classNames(
settingsStyles.card,
'bg-bolt-elements-background-depth-2',
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-500' : '',
)}
</div>
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-start gap-3">
<div
className={classNames(
updateMessage.includes('Update available')
? 'i-ph:warning-fill text-yellow-500'
: 'i-ph:info text-bolt-elements-textSecondary',
'text-xl flex-shrink-0',
)}
/>
<div className="flex-1">
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
{updateMessage.includes('Update available') && (
<div className="mt-3">
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
<ol className="list-decimal ml-4 mt-1 space-y-2">
<li className="text-bolt-elements-textSecondary">
<div className="flex items-center gap-2">
<div className="i-ph:git-branch text-purple-500" />
Pull the latest changes:{' '}
<code className="px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary">
git pull upstream main
</code>
</div>
</li>
<li className="text-bolt-elements-textSecondary">
<div className="flex items-center gap-2">
<div className="i-ph:package text-purple-500" />
Install any new dependencies:{' '}
<code className="px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary">
pnpm install
</code>
</div>
</li>
<li className="text-bolt-elements-textSecondary">
<div className="flex items-center gap-2">
<div className="i-ph:arrows-clockwise text-purple-500" />
Restart the application
</div>
</li>
</ol>
</div>
)}
</div>
</div>
</motion.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">
<motion.div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:desktop text-xl text-purple-500" />
<h4 className="text-md font-medium text-bolt-elements-textPrimary">System Information</h4>
</div>
<motion.div className={classNames(settingsStyles.card, 'bg-bolt-elements-background-depth-2')}>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:computer-tower text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
</div>
<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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:device-mobile text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Device Type</p>
</div>
<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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:browser text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Browser</p>
</div>
<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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:monitor text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Display</p>
</div>
<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">
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:wifi-high text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Connection</p>
</div>
<div className="flex items-center gap-2 mt-1">
<span
className={`inline-block w-2 h-2 rounded-full ${systemInfo.online ? 'bg-green-500' : 'bg-red-500'}`}
className={classNames('w-2 h-2 rounded-full', systemInfo.online ? 'bg-green-500' : 'bg-red-500')}
/>
<span className={`${systemInfo.online ? 'text-green-600' : 'text-red-600'}`}>
<span
className={classNames('text-sm font-medium', systemInfo.online ? 'text-green-500' : 'text-red-500')}
>
{systemInfo.online ? 'Online' : 'Offline'}
</span>
</p>
</div>
</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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:translate text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Language</p>
</div>
<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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:clock text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Timezone</p>
</div>
<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>
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:cpu text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">CPU Cores</p>
</div>
<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>
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor">
<div className="flex items-center gap-2 mb-1">
<div className="i-ph:git-commit text-bolt-elements-textSecondary" />
<p className="text-xs text-bolt-elements-textSecondary">Version</p>
</div>
<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">
@ -546,22 +624,31 @@ export default function DebugTab() {
</span>
</p>
</div>
</div>
</div>
</motion.div>
</motion.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">
<motion.div
className="space-y-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:robot text-xl text-purple-500" />
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Local LLM Status</h4>
</div>
<motion.div className={classNames(settingsStyles.card, 'bg-bolt-elements-background-depth-2')}>
<div className="divide-y divide-bolt-elements-borderColor">
{activeProviders.map((provider) => (
<div key={provider.name} className="p-3 flex flex-col space-y-2">
<div key={provider.name} className="p-4 first:pt-0 last:pb-0">
<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'
}`}
className={classNames(
'w-2 h-2 rounded-full',
!provider.enabled ? 'bg-gray-400' : provider.isRunning ? 'bg-green-500' : 'bg-red-500',
)}
/>
</div>
<div>
@ -575,17 +662,21 @@ export default function DebugTab() {
</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'
}`}
className={classNames(
'px-2 py-0.5 text-xs rounded-full',
provider.enabled
? 'bg-green-500/10 text-green-500'
: 'bg-gray-500/10 text-bolt-elements-textSecondary',
)}
>
{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'
}`}
className={classNames(
'px-2 py-0.5 text-xs rounded-full',
provider.isRunning ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500',
)}
>
{provider.isRunning ? 'Running' : 'Not Running'}
</span>
@ -593,31 +684,28 @@ export default function DebugTab() {
</div>
</div>
<div className="pl-5 flex flex-col space-y-1 text-xs">
{/* Status Details */}
<div className="pl-5 mt-2 space-y-2">
<div className="flex flex-wrap gap-2">
<span className="text-bolt-elements-textSecondary">
<span className="text-xs text-bolt-elements-textSecondary">
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
</span>
{provider.responseTime && (
<span className="text-bolt-elements-textSecondary">
<span className="text-xs 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">
<div className="mt-2 text-xs text-red-500 bg-red-500/10 rounded-md p-2">
<span className="font-medium">Error:</span> {provider.error}
</div>
)}
{/* Connection Info */}
{provider.url && (
<div className="text-bolt-elements-textSecondary">
<div className="text-xs text-bolt-elements-textSecondary mt-2">
<span className="font-medium">Endpoints checked:</span>
<ul className="list-disc list-inside pl-2 mt-1">
<ul className="list-disc list-inside pl-2 mt-1 space-y-1">
<li>{provider.url} (root)</li>
<li>{provider.url}/api/health</li>
<li>{provider.url}/v1/models</li>
@ -631,8 +719,8 @@ export default function DebugTab() {
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
</section>
</div>
);

View File

@ -1,22 +1,27 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import React, { useCallback, useEffect, useState, useMemo, useRef } 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';
import { motion } from 'framer-motion';
import { settingsStyles } from '~/components/settings/settings.styles';
export default function EventLogsTab() {
const {} = useSettings();
const showLogs = useStore(logStore.showLogs);
const logs = useStore(logStore.logs);
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [, forceUpdate] = useState({});
const logsContainerRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const filteredLogs = useMemo(() => {
const logs = logStore.getLogs();
return logs.filter((log) => {
const allLogs = Object.values(logs);
const filtered = allLogs.filter((log) => {
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
const matchesSearch =
!searchQuery ||
@ -25,7 +30,9 @@ export default function EventLogsTab() {
return matchesLevel && matchesSearch;
});
}, [logLevel, searchQuery]);
return filtered.reverse();
}, [logs, logLevel, searchQuery]);
// Effect to initialize showLogs
useEffect(() => {
@ -37,18 +44,51 @@ export default function EventLogsTab() {
logStore.logSystem('Application initialized', {
version: process.env.NEXT_PUBLIC_APP_VERSION,
environment: process.env.NODE_ENV,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
});
// Debug logs for system state
logStore.logDebug('System configuration loaded', {
runtime: 'Next.js',
features: ['AI Chat', 'Event Logging'],
features: ['AI Chat', 'Event Logging', 'Provider Management', 'Theme Support'],
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// Performance metrics
logStore.logSystem('Performance metrics', {
deviceMemory: (navigator as any).deviceMemory || 'unknown',
hardwareConcurrency: navigator.hardwareConcurrency,
connectionType: (navigator as any).connection?.effectiveType || 'unknown',
});
// Provider status
logStore.logProvider('Provider status check', {
availableProviders: ['OpenAI', 'Anthropic', 'Mistral', 'Ollama'],
defaultProvider: 'OpenAI',
status: 'operational',
});
// Theme and accessibility
logStore.logSystem('User preferences loaded', {
theme: document.documentElement.dataset.theme || 'system',
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
prefersDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
});
// Warning logs for potential issues
logStore.logWarning('Resource usage threshold approaching', {
memoryUsage: '75%',
cpuLoad: '60%',
timestamp: new Date().toISOString(),
});
// Security checks
logStore.logSystem('Security status', {
httpsEnabled: window.location.protocol === 'https:',
cookiesEnabled: navigator.cookieEnabled,
storageQuota: 'checking...',
});
// Error logs with detailed context
@ -56,16 +96,50 @@ export default function EventLogsTab() {
endpoint: '/api/chat',
retryCount: 3,
lastAttempt: new Date().toISOString(),
statusCode: 408,
});
// Debug logs for development
if (process.env.NODE_ENV === 'development') {
logStore.logDebug('Development mode active', {
debugFlags: true,
mockServices: false,
apiEndpoint: 'local',
});
}
}, []);
// Scroll handling
useEffect(() => {
const container = document.querySelector('.logs-container');
const container = logsContainerRef.current;
if (container && autoScroll) {
container.scrollTop = container.scrollHeight;
if (!container) {
return undefined;
}
}, [filteredLogs, autoScroll]);
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
setIsScrolledToBottom(isBottom);
};
container.addEventListener('scroll', handleScroll);
const cleanup = () => {
container.removeEventListener('scroll', handleScroll);
};
return cleanup;
}, []);
// Auto-scroll effect
useEffect(() => {
const container = logsContainerRef.current;
if (container && (autoScroll || isScrolledToBottom)) {
container.scrollTop = 0;
}
}, [filteredLogs, autoScroll, isScrolledToBottom]);
const handleClearLogs = useCallback(() => {
if (confirm('Are you sure you want to clear all logs?')) {
@ -103,33 +177,56 @@ export default function EventLogsTab() {
}
}, []);
const getLevelIcon = (level: LogEntry['level']): string => {
switch (level) {
case 'info':
return 'i-ph:info';
case 'warning':
return 'i-ph:warning';
case 'error':
return 'i-ph:x-circle';
case 'debug':
return 'i-ph:bug';
default:
return 'i-ph:circle';
}
};
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'info':
return 'text-blue-500';
return 'text-[#1389FD] dark:text-[#1389FD]';
case 'warning':
return 'text-yellow-500';
return 'text-[#FFDB6C] dark:text-[#FFDB6C]';
case 'error':
return 'text-red-500';
return 'text-[#EE4744] dark:text-[#EE4744]';
case 'debug':
return 'text-gray-500';
return 'text-[#77828D] dark:text-[#77828D]';
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">
<div className="space-y-4">
<div className="flex flex-col space-y-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 items-center gap-2">
<div className="i-ph:list-bullets text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
<p className="text-sm text-bolt-elements-textSecondary">Track system events and debug information</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
<div className="i-ph:eye text-bolt-elements-textSecondary" />
<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">
<div className="flex items-center gap-2">
<div className="i-ph:arrow-clockwise text-bolt-elements-textSecondary" />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
</div>
@ -137,83 +234,166 @@ export default function EventLogsTab() {
</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 flex-wrap items-center gap-4">
<div className="flex-1 min-w-[150px] max-w-[200px]">
<div className="relative group">
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
className={classNames(
'w-full pl-9 pr-3 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-sm 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',
)}
>
<option value="all">All Levels</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
<div className="i-ph:funnel absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</div>
</div>
<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 className="relative group">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-full pl-9 pr-3 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-sm text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'group-hover:border-purple-500/30',
'transition-all duration-200',
)}
/>
<div className="i-ph:magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</div>
</div>
{showLogs && (
<div className="flex items-center gap-2 flex-nowrap">
<button
<motion.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',
)}
className={classNames(settingsStyles.button.base, settingsStyles.button.primary, 'group')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:download-simple group-hover:scale-110 transition-transform" />
Export Logs
</button>
<button
</motion.button>
<motion.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',
)}
className={classNames(settingsStyles.button.base, settingsStyles.button.danger, 'group')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:trash group-hover:scale-110 transition-transform" />
Clear Logs
</button>
</motion.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>
))
<motion.div
ref={logsContainerRef}
className={classNames(
settingsStyles.card,
'h-[calc(100vh-250px)] min-h-[400px] overflow-y-auto logs-container',
'scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-purple-500/30',
)}
</div>
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', duration: 0.5 }}
className="i-ph:clipboard-text text-6xl text-bolt-elements-textSecondary mb-4"
/>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-bolt-elements-textSecondary"
>
No logs found
</motion.p>
</div>
) : (
<div className="divide-y divide-bolt-elements-borderColor">
{filteredLogs.map((log, index) => (
<motion.div
key={index}
className={classNames(
'p-4 font-mono hover:bg-bolt-elements-background-depth-3 transition-colors duration-200',
{ 'border-t border-bolt-elements-borderColor': index === 0 },
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<div className="flex items-start gap-3">
<div
className={classNames(
getLevelIcon(log.level),
getLevelColor(log.level),
'mt-1 flex-shrink-0 text-lg',
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={classNames(
'font-bold whitespace-nowrap px-2 py-0.5 rounded-full text-xs',
{
'bg-blue-500/10': log.level === 'info',
'bg-yellow-500/10': log.level === 'warning',
'bg-red-500/10': log.level === 'error',
'bg-bolt-elements-textSecondary/10': log.level === 'debug',
},
getLevelColor(log.level),
)}
>
{log.level.toUpperCase()}
</span>
<span className="text-bolt-elements-textSecondary whitespace-nowrap text-xs">
{new Date(log.timestamp).toLocaleString()}
</span>
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
</div>
{log.details && (
<motion.pre
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className={classNames(
'mt-2 text-xs',
'overflow-x-auto whitespace-pre-wrap break-all',
'bg-[#1A1A1A] dark:bg-[#0A0A0A] rounded-md p-3',
'border border-[#333333] dark:border-[#1A1A1A]',
'text-[#666666] dark:text-[#999999]',
)}
>
{JSON.stringify(log.details, null, 2)}
</motion.pre>
)}
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
</div>
);
}

View File

@ -1,7 +1,22 @@
import React from 'react';
import React, { useState } from 'react';
import { Switch } from '~/components/ui/Switch';
import { PromptLibrary } from '~/lib/common/prompt-library';
import { useSettings } from '~/lib/hooks/useSettings';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
import { toast } from 'react-toastify';
interface FeatureToggle {
id: string;
title: string;
description: string;
icon: string;
enabled: boolean;
beta?: boolean;
experimental?: boolean;
tooltip?: string;
}
export default function FeaturesTab() {
const {
@ -20,88 +35,266 @@ export default function FeaturesTab() {
contextOptimizationEnabled,
} = useSettings();
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
const [expandedFeature, setExpandedFeature] = useState<string | null>(null);
const handleToggle = (enabled: boolean) => {
enableDebugMode(enabled);
enableEventLogs(enabled);
toast.success(`Debug features ${enabled ? 'enabled' : 'disabled'}`);
};
const features: FeatureToggle[] = [
{
id: 'debug',
title: 'Debug Features',
description: 'Enable debugging tools and detailed logging',
icon: 'i-ph:bug',
enabled: debug,
experimental: true,
tooltip: 'Access advanced debugging tools and view detailed system logs',
},
{
id: 'latestBranch',
title: 'Use Main Branch',
description: 'Check for updates against the main branch instead of stable',
icon: 'i-ph:git-branch',
enabled: isLatestBranch,
beta: true,
tooltip: 'Get the latest features and improvements before they are officially released',
},
{
id: 'autoTemplate',
title: 'Auto Select Code Template',
description: 'Let Bolt select the best starter template for your project',
icon: 'i-ph:magic-wand',
enabled: autoSelectTemplate,
tooltip: 'Automatically choose the most suitable template based on your project type',
},
{
id: 'contextOptimization',
title: 'Context Optimization',
description: 'Optimize chat context by redacting file contents and using system prompts',
icon: 'i-ph:arrows-in',
enabled: contextOptimizationEnabled,
tooltip: 'Improve AI responses by optimizing the context window and system prompts',
},
{
id: 'experimentalProviders',
title: 'Experimental Providers',
description: 'Enable experimental providers like Ollama, LMStudio, and OpenAILike',
icon: 'i-ph:robot',
enabled: isLocalModel,
experimental: true,
tooltip: 'Try out new AI providers and models in development',
},
];
const handleToggleFeature = (featureId: string, enabled: boolean) => {
switch (featureId) {
case 'debug':
handleToggle(enabled);
break;
case 'latestBranch':
enableLatestBranch(enabled);
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'autoTemplate':
setAutoSelectTemplate(enabled);
toast.success(`Auto template selection ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'contextOptimization':
enableContextOptimization(enabled);
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'experimentalProviders':
enableLocalModels(enabled);
toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`);
break;
}
};
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 className="space-y-6">
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="i-ph:puzzle-piece text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Features</h3>
<p className="text-sm text-bolt-elements-textSecondary">Customize your Bolt experience</p>
</div>
</div>
</motion.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]"
<motion.div
className="grid grid-cols-1 md:grid-cols-2 gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{features.map((feature, index) => (
<motion.div
key={feature.id}
className={classNames(
settingsStyles.card,
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-colors duration-200',
'relative overflow-hidden group cursor-pointer',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onHoverStart={() => setHoveredFeature(feature.id)}
onHoverEnd={() => setHoveredFeature(null)}
onClick={() => setExpandedFeature(expandedFeature === feature.id ? null : feature.id)}
>
{PromptLibrary.getList().map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<AnimatePresence>
{hoveredFeature === feature.id && feature.tooltip && (
<motion.div
className={classNames(
'absolute -top-12 left-1/2 transform -translate-x-1/2',
'px-3 py-2 rounded-lg text-xs',
'bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary',
'border border-bolt-elements-borderColor',
'whitespace-nowrap z-10',
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
>
{feature.tooltip}
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2 rotate-45 w-2 h-2 bg-bolt-elements-background-depth-4 border-r border-b border-bolt-elements-borderColor" />
</motion.div>
)}
</AnimatePresence>
<div className="absolute top-0 right-0 p-2 flex gap-1">
{feature.beta && (
<motion.span
className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Beta
</motion.span>
)}
{feature.experimental && (
<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 }}
>
Experimental
</motion.span>
)}
</div>
<div className="flex items-start gap-4 p-4">
<motion.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',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className={classNames(feature.icon, 'text-purple-500')} />
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{feature.title}
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{feature.description}</p>
</div>
<Switch
checked={feature.enabled}
onCheckedChange={(checked) => handleToggleFeature(feature.id, checked)}
/>
</div>
</div>
</div>
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: feature.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: feature.enabled ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
</motion.div>
<motion.div
className={classNames(
settingsStyles.card,
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-start gap-4 p-4">
<motion.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',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph:book text-purple-500" />
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4">
<div>
<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>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,399 @@
import React, { useState, useRef, useEffect } from 'react';
import { AnimatePresence } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Switch } from '~/components/ui/Switch';
import type { UserProfile } from '~/components/settings/settings.types';
import { themeStore, kTheme } from '~/lib/stores/theme';
import { motion } from 'framer-motion';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const MIN_PASSWORD_LENGTH = 8;
export default function ProfileTab() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [currentTimezone, setCurrentTimezone] = useState('');
const [profile, setProfile] = useState<UserProfile>(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved
? JSON.parse(saved)
: {
name: '',
email: '',
theme: 'system',
notifications: true,
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
password: '',
bio: '',
};
});
useEffect(() => {
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
// Apply theme when profile changes
useEffect(() => {
if (profile.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');
} else {
// Set specific theme
localStorage.setItem(kTheme, profile.theme);
document.querySelector('html')?.setAttribute('data-theme', profile.theme);
themeStore.set(profile.theme);
}
}, [profile.theme]);
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
toast.error('Please upload a valid image file (JPEG, PNG, or GIF)');
return;
}
if (file.size > MAX_FILE_SIZE) {
toast.error('File size must be less than 5MB');
return;
}
setIsLoading(true);
try {
const reader = new FileReader();
reader.onloadend = () => {
setProfile((prev) => ({ ...prev, avatar: reader.result as string }));
setIsLoading(false);
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error uploading avatar:', error);
toast.error('Failed to upload avatar');
setIsLoading(false);
}
};
const handleSave = async () => {
if (!profile.name.trim()) {
toast.error('Name is required');
return;
}
if (!profile.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email)) {
toast.error('Please enter a valid email address');
return;
}
if (profile.password && profile.password.length < MIN_PASSWORD_LENGTH) {
toast.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters long`);
return;
}
setIsLoading(true);
try {
localStorage.setItem('bolt_user_profile', JSON.stringify(profile));
toast.success('Profile settings saved successfully');
} catch (error) {
console.error('Error saving profile:', error);
toast.error('Failed to save profile settings');
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{/* Profile Information */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<div className="i-ph:user-circle-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Personal Information</span>
</div>
<div className="flex items-start gap-4 p-4">
{/* Avatar */}
<div className="relative group">
<div className="w-12 h-12 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] flex items-center justify-center overflow-hidden">
<AnimatePresence mode="wait">
{isLoading ? (
<div className="i-ph:spinner-gap-bold animate-spin text-purple-500" />
) : profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="i-ph:user-circle-fill text-bolt-elements-textSecondary" />
)}
</AnimatePresence>
</div>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg"
>
<div className="i-ph:camera-fill text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_FILE_TYPES.join(',')}
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{/* Profile Fields */}
<div className="flex-1 space-y-3">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:user-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="text"
value={profile.name}
onChange={(e) => setProfile((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Enter your name"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:envelope-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={profile.password}
onChange={(e) => setProfile((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Enter new password"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] 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',
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={classNames(
'absolute right-3 top-1/2 -translate-y-1/2',
'flex items-center justify-center',
'w-6 h-6 rounded-md',
'text-bolt-elements-textSecondary',
'hover:text-bolt-elements-item-contentActive',
'hover:bg-bolt-elements-item-backgroundActive',
'transition-colors',
)}
>
<div className={classNames(showPassword ? 'i-ph:eye-slash-fill' : 'i-ph:eye-fill', 'w-4 h-4')} />
</button>
</div>
</div>
</div>
</motion.div>
{/* 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.2 }}
>
<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={() => setProfile((prev) => ({ ...prev, theme }))}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 transition-colors',
profile.theme === theme
? 'bg-purple-500 text-white hover:bg-purple-600'
: 'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#252525] hover:text-bolt-elements-textPrimary',
)}
>
<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={profile.language}
onChange={(e) => setProfile((prev) => ({ ...prev, language: e.target.value }))}
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
>
<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">
{profile.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
</span>
<Switch
checked={profile.notifications}
onCheckedChange={(checked) => setProfile((prev) => ({ ...prev, notifications: checked }))}
/>
</div>
</div>
</motion.div>
{/* Timezone */}
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4">
<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 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>
<div className="flex gap-2">
<select
value={profile.timezone}
onChange={(e) => setProfile((prev) => ({ ...prev, timezone: e.target.value }))}
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
>
{Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}>
{tz.replace(/_/g, ' ')}
</option>
))}
</select>
<button
onClick={() => setProfile((prev) => ({ ...prev, timezone: currentTimezone }))}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary',
'hover:text-bolt-elements-textPrimary',
)}
>
<div className="i-ph:crosshair-simple-fill" />
Auto-detect
</button>
</div>
</div>
</div>
{/* Save Button */}
<motion.div
className="flex justify-end mt-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<button
onClick={handleSave}
disabled={isLoading}
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',
)}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin" />
Saving...
</>
) : (
<>
<div className="i-ph:check-circle-fill" />
Save Changes
</>
)}
</button>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,295 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
import { DialogTitle, DialogDescription } from '~/components/ui/Dialog';
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 OllamaTagResponse {
models: Array<{
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
}>;
}
interface OllamaPullResponse {
status: string;
digest?: string;
total?: number;
completed?: number;
}
export default function OllamaModelUpdater() {
const [models, setModels] = useState<OllamaModel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
useEffect(() => {
fetchModels();
}, []);
const fetchModels = async () => {
try {
setIsLoading(true);
const response = await fetch('http://localhost:11434/api/tags');
const data = (await response.json()) as OllamaTagResponse;
setModels(
data.models.map((model) => ({
name: model.name,
digest: model.digest,
size: model.size,
modified_at: model.modified_at,
details: model.details,
status: 'idle' as const,
})),
);
} catch (error) {
toast.error('Failed to fetch Ollama models');
console.error('Error fetching models:', error);
} finally {
setIsLoading(false);
}
};
const updateModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => {
try {
const response = await fetch('http://localhost:11434/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 data = JSON.parse(line) as OllamaPullResponse;
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: data.completed || 0,
total: data.total || 0,
status: data.status,
},
newDigest: data.digest,
}
: m,
),
);
}
}
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'checking' } : m)));
const updatedResponse = await fetch('http://localhost:11434/api/tags');
const data = (await updatedResponse.json()) as OllamaTagResponse;
const updatedModel = data.models.find((m) => m.name === modelName);
return { success: true, newDigest: updatedModel?.digest };
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return { success: false };
}
};
const handleBulkUpdate = async () => {
setIsBulkUpdating(true);
for (const model of models) {
setModels((current) => current.map((m) => (m.name === model.name ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(model.name);
setModels((current) =>
current.map((m) =>
m.name === model.name
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
}
setIsBulkUpdating(false);
toast.success('Bulk update completed');
};
const handleSingleUpdate = async (modelName: string) => {
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(modelName);
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
if (success) {
toast.success(`Updated ${modelName}`);
} else {
toast.error(`Failed to update ${modelName}`);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<div className={settingsStyles['loading-spinner']} />
<span className="ml-2 text-bolt-elements-textSecondary">Loading models...</span>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-2">
<DialogTitle>Ollama Model Manager</DialogTitle>
<DialogDescription>Update your local Ollama models to their latest versions</DialogDescription>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:arrows-clockwise text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{models.length} models available</span>
</div>
<motion.button
onClick={handleBulkUpdate}
disabled={isBulkUpdating}
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isBulkUpdating ? (
<>
<div className={settingsStyles['loading-spinner']} />
Updating All...
</>
) : (
<>
<div className="i-ph:arrows-clockwise" />
Update All Models
</>
)}
</motion.button>
</div>
<div className="space-y-2">
{models.map((model) => (
<div
key={model.name}
className={classNames(
'flex items-center justify-between p-3 rounded-lg',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
)}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="i-ph:cube text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
{model.status === 'updating' && <div className={settingsStyles['loading-spinner']} />}
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
<span>Version: {model.digest.substring(0, 7)}</span>
{model.status === 'updated' && model.newDigest && (
<>
<div className="i-ph:arrow-right w-3 h-3" />
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
</>
)}
{model.progress && (
<span className="ml-2">
{model.progress.status}{' '}
{model.progress.total > 0 && (
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
)}
</span>
)}
{model.details && (
<span className="ml-2">
({model.details.parameter_size}, {model.details.quantization_level})
</span>
)}
</div>
</div>
<motion.button
onClick={() => handleSingleUpdate(model.name)}
disabled={model.status === 'updating'}
className={classNames(settingsStyles.button.base, settingsStyles.button.secondary)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update
</motion.button>
</div>
))}
</div>
</div>
);
}

View File

@ -1,34 +1,157 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Switch } from '~/components/ui/Switch';
import Separator from '~/components/ui/Separator';
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 { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
import { toast } from 'react-toastify';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { SiAmazon, SiOpenai, SiGoogle, SiHuggingface, SiPerplexity } from 'react-icons/si';
import { BsRobot, BsCloud, BsCodeSquare, BsCpu, BsBox } from 'react-icons/bs';
import { TbBrandOpenai, 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';
import OllamaModelUpdater from './OllamaModelUpdater';
import { DialogRoot, Dialog } from '~/components/ui/Dialog';
const DefaultIcon = '/icons/Default.svg'; // Adjust the path as necessary
// Add type for provider names to ensure type safety
type ProviderName =
| 'AmazonBedrock'
| 'Anthropic'
| 'Cohere'
| 'Deepseek'
| 'Google'
| 'Groq'
| 'HuggingFace'
| 'Hyperbolic'
| 'LMStudio'
| 'Mistral'
| 'Ollama'
| 'OpenAI'
| 'OpenAILike'
| '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: BsCpu,
HuggingFace: SiHuggingface,
Hyperbolic: TbCloudComputing,
LMStudio: BsCodeSquare,
Mistral: TbBrain,
Ollama: BsBox,
OpenAI: SiOpenai,
OpenAILike: TbBrandOpenai,
OpenRouter: FaCloud,
Perplexity: SiPerplexity,
Together: BsCloud,
XAI: BsRobot,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
Anthropic: 'Access Claude and other Anthropic models',
Ollama: 'Run open-source models locally on your machine',
LMStudio: 'Local model inference with LM Studio',
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
};
// Add these types and helper functions
type ProviderCategory = 'cloud' | 'local';
interface ProviderGroup {
title: string;
description: string;
icon: string;
providers: IProviderConfig[];
}
// Add this type
interface CategoryToggleState {
cloud: boolean;
local: boolean;
}
export default function ProvidersTab() {
const { providers, updateProviderSettings, isLocalModel } = useSettings();
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<CategoryToggleState>({
cloud: false,
local: false,
});
const [showOllamaUpdater, setShowOllamaUpdater] = useState(false);
// Load base URLs from cookies
const [searchTerm, setSearchTerm] = useState('');
// Group providers by category
const groupedProviders = useMemo(() => {
const groups: Record<ProviderCategory, ProviderGroup> = {
cloud: {
title: 'Cloud Providers',
description: 'AI models hosted on cloud platforms',
icon: 'i-ph:cloud-duotone',
providers: [],
},
local: {
title: 'Local Providers',
description: 'Run models locally on your machine',
icon: 'i-ph:desktop-duotone',
providers: [],
},
};
filteredProviders.forEach((provider) => {
const category: ProviderCategory = LOCAL_PROVIDERS.includes(provider.name) ? 'local' : 'cloud';
groups[category].providers.push(provider);
});
return groups;
}, [filteredProviders]);
// Update the toggle handler
const handleToggleCategory = useCallback(
(category: ProviderCategory, enabled: boolean) => {
setCategoryEnabled((prev) => ({ ...prev, [category]: enabled }));
// Get providers for this category
const categoryProviders = groupedProviders[category].providers;
categoryProviders.forEach((provider) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
toast.success(enabled ? `All ${category} providers enabled` : `All ${category} providers disabled`);
},
[groupedProviders, updateProviderSettings],
);
// Add effect to update category toggle states based on provider states
useEffect(() => {
const newCategoryState = {
cloud: groupedProviders.cloud.providers.every((p) => p.settings.enabled),
local: groupedProviders.local.providers.every((p) => p.settings.enabled),
};
setCategoryEnabled(newCategoryState);
}, [groupedProviders]);
// Effect to filter and sort providers
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));
}
@ -40,108 +163,245 @@ export default function ProvidersTab() {
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
setFilteredProviders([...regular, ...urlConfigurable]);
}, [providers, searchTerm, isLocalModel]);
}, [providers, 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);
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
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>
);
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`);
}
};
const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const handleUpdateBaseUrl = (provider: IProviderConfig, baseUrl: string) => {
let newBaseUrl: string | undefined = baseUrl;
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,
});
toast.success(`${provider.name} base URL updated`);
setEditingProvider(null);
};
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>
<div className="space-y-6">
{Object.entries(groupedProviders).map(([category, group]) => (
<motion.div
key={category}
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',
)}
>
<div className={group.icon} />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">{group.title}</h4>
<p className="text-sm text-bolt-elements-textSecondary">{group.description}</p>
</div>
</div>
{/* Regular Providers Grid */}
<div className="grid grid-cols-2 gap-4 mb-8">{regularProviders.map(renderProviderCard)}</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">
Enable All {category === 'cloud' ? 'Cloud' : 'Local'}
</span>
<Switch
checked={categoryEnabled[category as ProviderCategory]}
onCheckedChange={(checked) => handleToggleCategory(category as ProviderCategory, checked)}
/>
</div>
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.providers.map((provider, index) => (
<motion.div
key={provider.name}
className={classNames(
settingsStyles.card,
'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">
{LOCAL_PROVIDERS.includes(provider.name) && (
<motion.span
className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500 font-medium"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Local
</motion.span>
)}
{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 }}
/>
{provider.name === 'Ollama' && provider.settings.enabled && (
<motion.button
onClick={() => setShowOllamaUpdater(true)}
className={classNames(settingsStyles.button.base, settingsStyles.button.secondary, 'ml-2')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update Models
</motion.button>
)}
<DialogRoot open={showOllamaUpdater} onOpenChange={setShowOllamaUpdater}>
<Dialog>
<div className="p-6">
<OllamaModelUpdater />
</div>
</Dialog>
</DialogRoot>
</motion.div>
))}
</div>
{category === 'cloud' && <Separator className="my-8" />}
</motion.div>
))}
</div>
);
}

View File

@ -0,0 +1,37 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const settingsStyles = {
// Card styles
card: 'bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]',
// Button styles
button: {
base: 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed',
primary: 'bg-purple-500 text-white hover:bg-purple-600',
secondary:
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white',
danger: 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20',
warning: 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
success: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-500/10 dark:hover:bg-green-500/20',
},
// Form styles
form: {
label: 'block text-sm text-bolt-elements-textSecondary mb-2',
input:
'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',
},
// Search container
search: {
input:
'w-full h-10 pl-10 pr-4 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 transition-all',
},
'loading-spinner': 'i-ph:spinner-gap-bold animate-spin w-4 h-4',
} as const;

View File

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
export type TabType =
| 'profile'
| 'data'
| 'providers'
| 'features'
| 'debug'
| 'event-logs'
| 'connection'
| 'preferences';
export interface UserProfile {
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 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',
};

View File

@ -7,6 +7,51 @@ 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', {
'bg-purple-500 text-white hover:bg-purple-600': type === 'primary',
'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary': type === 'secondary',
'text-red-500 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 +85,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 +125,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,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

@ -24,6 +24,11 @@ class LogStore {
this._loadLogs();
}
// Expose the logs store for subscription
get logs() {
return this._logs;
}
private _loadLogs() {
const savedLogs = Cookies.get('eventLogs');

View File

@ -30,12 +30,12 @@
"node": ">=18.18.0"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "1.0.6",
"@ai-sdk/anthropic": "^0.0.39",
"@ai-sdk/cohere": "^1.0.3",
"@ai-sdk/google": "^0.0.52",
"@ai-sdk/mistral": "^0.0.43",
"@ai-sdk/openai": "^0.0.66",
"@ai-sdk/amazon-bedrock": "1.0.6",
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-cpp": "^6.0.2",
@ -52,7 +52,7 @@
"@codemirror/search": "^6.5.8",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.35.0",
"@iconify-json/ph": "^1.2.1",
"@headlessui/react": "^2.2.0",
"@iconify-json/svg-spinners": "^1.2.1",
"@lezer/highlight": "^1.2.1",
"@nanostores/react": "^0.7.3",
@ -76,6 +76,7 @@
"@xterm/xterm": "^5.5.0",
"ai": "^4.0.13",
"chalk": "^5.4.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
@ -93,6 +94,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
"react-toastify": "^10.0.6",
@ -102,11 +104,14 @@
"remix-island": "^0.2.0",
"remix-utils": "^7.7.0",
"shiki": "^1.24.0",
"tailwind-merge": "^2.6.0",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@blitz/eslint-plugin": "0.1.0",
"@cloudflare/workers-types": "^4.20241127.0",
"@iconify-json/ph": "^1.2.1",
"@iconify/types": "^2.0.0",
"@remix-run/dev": "^2.15.0",
"@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4",

171
pnpm-lock.yaml generated
View File

@ -77,9 +77,9 @@ importers:
'@codemirror/view':
specifier: ^6.35.0
version: 6.35.0
'@iconify-json/ph':
specifier: ^1.2.1
version: 1.2.1
'@headlessui/react':
specifier: ^2.2.0
version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@iconify-json/svg-spinners':
specifier: ^1.2.1
version: 1.2.1
@ -149,6 +149,9 @@ importers:
chalk:
specifier: ^5.4.1
version: 5.4.1
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^3.6.0
version: 3.6.0
@ -200,6 +203,9 @@ importers:
react-hotkeys-hook:
specifier: ^4.6.1
version: 4.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-icons:
specifier: ^5.4.0
version: 5.4.0(react@18.3.1)
react-markdown:
specifier: ^9.0.1
version: 9.0.1(@types/react@18.3.12)(react@18.3.1)
@ -227,6 +233,9 @@ importers:
shiki:
specifier: ^1.24.0
version: 1.24.0
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
@ -237,6 +246,12 @@ importers:
'@cloudflare/workers-types':
specifier: ^4.20241127.0
version: 4.20241127.0
'@iconify-json/ph':
specifier: ^1.2.1
version: 1.2.1
'@iconify/types':
specifier: ^2.0.0
version: 2.0.0
'@remix-run/dev':
specifier: ^2.15.0
version: 2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@types/node@22.10.1)(sass-embedded@1.81.0)(typescript@5.7.2)(vite@5.4.11(@types/node@22.10.1)(sass-embedded@1.81.0))(wrangler@3.91.0(@cloudflare/workers-types@4.20241127.0))
@ -1437,9 +1452,22 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
'@headlessui/react@2.2.0':
resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -1997,6 +2025,40 @@ packages:
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
'@react-aria/focus@3.19.1':
resolution: {integrity: sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.23.0':
resolution: {integrity: sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.7':
resolution: {integrity: sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.27.0':
resolution: {integrity: sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/utils@3.10.5':
resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.27.0':
resolution: {integrity: sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@remix-run/cloudflare-pages@2.15.0':
resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==}
engines: {node: '>=18.0.0'}
@ -2398,6 +2460,18 @@ packages:
peerDependencies:
eslint: '>=8.40.0'
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tanstack/react-virtual@3.11.2':
resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.11.2':
resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@ -4965,6 +5039,11 @@ packages:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-icons@5.4.0:
resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==}
peerDependencies:
react: '*'
react-markdown@9.0.1:
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
peerDependencies:
@ -5559,6 +5638,12 @@ packages:
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
engines: {node: ^14.18.0 || >=16.0.0}
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tar-fs@2.1.1:
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
@ -7372,8 +7457,25 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/react@0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/utils': 0.2.8
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tabbable: 6.2.0
'@floating-ui/utils@0.2.8': {}
'@headlessui/react@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/focus': 3.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/interactions': 3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual': 3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@ -7980,6 +8082,49 @@ snapshots:
'@radix-ui/rect@1.1.0': {}
'@react-aria/focus@3.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-types/shared': 3.27.0(react@18.3.1)
'@swc/helpers': 0.5.15
clsx: 2.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@react-aria/interactions@3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/ssr': 3.9.7(react@18.3.1)
'@react-aria/utils': 3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-types/shared': 3.27.0(react@18.3.1)
'@swc/helpers': 0.5.15
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@react-aria/ssr@3.9.7(react@18.3.1)':
dependencies:
'@swc/helpers': 0.5.15
react: 18.3.1
'@react-aria/utils@3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/ssr': 3.9.7(react@18.3.1)
'@react-stately/utils': 3.10.5(react@18.3.1)
'@react-types/shared': 3.27.0(react@18.3.1)
'@swc/helpers': 0.5.15
clsx: 2.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@react-stately/utils@3.10.5(react@18.3.1)':
dependencies:
'@swc/helpers': 0.5.15
react: 18.3.1
'@react-types/shared@3.27.0(react@18.3.1)':
dependencies:
react: 18.3.1
'@remix-run/cloudflare-pages@2.15.0(@cloudflare/workers-types@4.20241127.0)(typescript@5.7.2)':
dependencies:
'@cloudflare/workers-types': 4.20241127.0
@ -8543,6 +8688,18 @@ snapshots:
- supports-color
- typescript
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@tanstack/react-virtual@3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.11.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.11.2': {}
'@types/acorn@4.0.6':
dependencies:
'@types/estree': 1.0.6
@ -11823,6 +11980,10 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-icons@5.4.0(react@18.3.1):
dependencies:
react: 18.3.1
react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1):
dependencies:
'@types/hast': 3.0.4
@ -12456,6 +12617,10 @@ snapshots:
'@pkgr/core': 0.1.1
tslib: 2.8.1
tabbable@6.2.0: {}
tailwind-merge@2.6.0: {}
tar-fs@2.1.1:
dependencies:
chownr: 1.1.4

View File

@ -1,23 +1,43 @@
import { globSync } from 'fast-glob';
import fs from 'node:fs/promises';
import { basename } from 'node:path';
import { basename, join } from 'node:path';
import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss';
import type { IconifyJSON } from '@iconify/types';
const iconPaths = globSync('./icons/*.svg');
// Debug: Log the current working directory and icon paths
console.log('CWD:', process.cwd());
const iconPaths = globSync(join(process.cwd(), 'public/icons/*.svg'));
console.log('Found icons:', iconPaths);
const collectionName = 'bolt';
const customIconCollection = iconPaths.reduce(
(acc, iconPath) => {
const [iconName] = basename(iconPath).split('.');
const customIconCollection = {
[collectionName]: iconPaths.reduce(
(acc, iconPath) => {
const [iconName] = basename(iconPath).split('.');
acc[collectionName] ??= {};
acc[collectionName][iconName] = async () => fs.readFile(iconPath, 'utf8');
acc[iconName] = async () => {
try {
const content = await fs.readFile(iconPath, 'utf8');
return content
.replace(/fill="[^"]*"/g, '')
.replace(/fill='[^']*'/g, '')
.replace(/width="[^"]*"/g, '')
.replace(/height="[^"]*"/g, '')
.replace(/viewBox="[^"]*"/g, 'viewBox="0 0 24 24"')
.replace(/<svg([^>]*)>/, '<svg $1 fill="currentColor">');
} catch (error) {
console.error(`Error loading icon ${iconName}:`, error);
return '';
}
};
return acc;
},
{} as Record<string, Record<string, () => Promise<string>>>,
);
return acc;
},
{} as Record<string, () => Promise<string>>,
),
};
const BASE_COLORS = {
white: '#FFFFFF',
@ -98,9 +118,7 @@ const COLOR_PRIMITIVES = {
};
export default defineConfig({
safelist: [
...Object.keys(customIconCollection[collectionName]||{}).map(x=>`i-bolt:${x}`)
],
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
shortcuts: {
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
@ -242,9 +260,27 @@ export default defineConfig({
presetIcons({
warn: true,
collections: {
...customIconCollection,
bolt: customIconCollection.bolt,
ph: async () => {
const icons = await import('@iconify-json/ph/icons.json');
return icons.default as IconifyJSON;
},
},
extraProperties: {
display: 'inline-block',
'vertical-align': 'middle',
width: '24px',
height: '24px',
},
customizations: {
customize(props) {
return {
...props,
width: '24px',
height: '24px',
};
},
},
unit: 'em',
}),
],
});