mirror of
https://github.com/stackblitz-labs/bolt.diy.git
synced 2025-06-18 01:49:07 +01:00
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:
parent
41bb909f8d
commit
f33ba635e8
39
.windsurf/config.json
Normal file
39
.windsurf/config.json
Normal 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
103
.windsurf/rules.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
399
app/components/settings/profile/ProfileTab.tsx
Normal file
399
app/components/settings/profile/ProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
295
app/components/settings/providers/OllamaModelUpdater.tsx
Normal file
295
app/components/settings/providers/OllamaModelUpdater.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
37
app/components/settings/settings.styles.ts
Normal file
37
app/components/settings/settings.styles.ts
Normal 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;
|
53
app/components/settings/settings.types.ts
Normal file
53
app/components/settings/settings.types.ts
Normal 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',
|
||||
};
|
@ -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>
|
||||
|
22
app/components/ui/Separator.tsx
Normal file
22
app/components/ui/Separator.tsx
Normal 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;
|
@ -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');
|
||||
|
||||
|
@ -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
171
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user