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:
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 * as RadixDialog from '@radix-ui/react-dialog';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useState, type ReactElement } from 'react';
|
import { useState } from 'react';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog';
|
import { DialogTitle } from '~/components/ui/Dialog';
|
||||||
import { IconButton } from '~/components/ui/IconButton';
|
import type { SettingCategory, TabType } from './settings.types';
|
||||||
import styles from './Settings.module.scss';
|
import { categoryLabels, categoryIcons } from './settings.types';
|
||||||
|
import ProfileTab from './profile/ProfileTab';
|
||||||
import ProvidersTab from './providers/ProvidersTab';
|
import ProvidersTab from './providers/ProvidersTab';
|
||||||
import { useSettings } from '~/lib/hooks/useSettings';
|
import { useSettings } from '~/lib/hooks/useSettings';
|
||||||
import FeaturesTab from './features/FeaturesTab';
|
import FeaturesTab from './features/FeaturesTab';
|
||||||
@@ -18,110 +19,281 @@ interface SettingsProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'data' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
|
|
||||||
|
|
||||||
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
||||||
const { debug, eventLogs } = useSettings();
|
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 }[] = [
|
const settingItems = [
|
||||||
{ id: 'data', label: 'Data', icon: 'i-ph:database', component: <DataTab /> },
|
{
|
||||||
{ id: 'providers', label: 'Providers', icon: 'i-ph:key', component: <ProvidersTab /> },
|
id: 'profile' as const,
|
||||||
{ id: 'connection', label: 'Connection', icon: 'i-ph:link', component: <ConnectionsTab /> },
|
label: 'Profile Settings',
|
||||||
{ id: 'features', label: 'Features', icon: 'i-ph:star', component: <FeaturesTab /> },
|
icon: 'i-ph:user-circle',
|
||||||
...(debug
|
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 TabType,
|
id: 'debug' as const,
|
||||||
label: 'Debug Tab',
|
label: 'Debug',
|
||||||
icon: 'i-ph:bug',
|
icon: 'i-ph:bug',
|
||||||
component: <DebugTab />,
|
category: 'system' as const,
|
||||||
|
description: 'Advanced debugging tools and options',
|
||||||
|
component: () => <DebugTab />,
|
||||||
|
keywords: ['debug', 'logs', 'developer'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: [];
|
||||||
...(eventLogs
|
|
||||||
|
const eventLogItems = eventLogs
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'event-logs' as TabType,
|
id: 'event-logs' as const,
|
||||||
label: 'Event Logs',
|
label: 'Event Logs',
|
||||||
icon: 'i-ph:list-bullets',
|
icon: 'i-ph:list-bullets',
|
||||||
component: <EventLogsTab />,
|
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 (
|
return (
|
||||||
<RadixDialog.Root open={open}>
|
<RadixDialog.Root open={open}>
|
||||||
<RadixDialog.Portal>
|
<RadixDialog.Portal>
|
||||||
<RadixDialog.Overlay asChild onClick={onClose}>
|
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||||
|
<RadixDialog.Overlay asChild>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-black/50 fixed inset-0 z-max backdrop-blur-sm"
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
initial="closed"
|
initial={{ opacity: 0 }}
|
||||||
animate="open"
|
animate={{ opacity: 1 }}
|
||||||
exit="closed"
|
exit={{ opacity: 0 }}
|
||||||
variants={dialogBackdropVariants}
|
transition={{ duration: 0.2 }}
|
||||||
/>
|
/>
|
||||||
</RadixDialog.Overlay>
|
</RadixDialog.Overlay>
|
||||||
<RadixDialog.Content aria-describedby={undefined} asChild>
|
<RadixDialog.Content aria-describedby={undefined} asChild>
|
||||||
<motion.div
|
<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(
|
className={classNames(
|
||||||
'w-48 border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 p-4 flex flex-col justify-between',
|
'relative',
|
||||||
styles['settings-tabs'],
|
'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 }}
|
||||||
>
|
>
|
||||||
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-bolt-elements-textPrimary mb-2">
|
<AnimatePresence mode="wait">
|
||||||
Settings
|
{activeTab ? (
|
||||||
</DialogTitle>
|
<motion.div
|
||||||
{tabs.map((tab) => (
|
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">
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
onClick={() => setActiveTab(null)}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
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"
|
||||||
className={classNames(activeTab === tab.id ? styles.active : '')}
|
|
||||||
>
|
>
|
||||||
<div className={tab.icon} />
|
<div className="i-ph:arrow-left w-4 h-4" />
|
||||||
{tab.label}
|
Back to Settings
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
<div className="text-bolt-elements-textTertiary mx-6 select-none">|</div>
|
||||||
<a
|
|
||||||
href="https://github.com/stackblitz-labs/bolt.diy"
|
{activeTabItem && (
|
||||||
target="_blank"
|
<div className="flex items-center gap-4">
|
||||||
rel="noopener noreferrer"
|
<div className={classNames(activeTabItem.icon, 'w-6 h-6 text-purple-500')} />
|
||||||
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
|
<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:github-logo" />
|
<div className="i-ph:house w-4 h-4" />
|
||||||
GitHub
|
Back to Bolt DIY
|
||||||
</a>
|
</button>
|
||||||
<a
|
</div>
|
||||||
href="https://stackblitz-labs.github.io/bolt.diy/"
|
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
||||||
target="_blank"
|
{allSettingItems.find((item) => item.id === activeTab)?.component()}
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
|
</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="i-ph:book" />
|
<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">
|
||||||
Docs
|
<div className="flex items-center gap-3">
|
||||||
</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col p-8 pt-10 bg-bolt-elements-background-depth-2">
|
<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="flex-1 overflow-y-auto">{tabs.find((tab) => tab.id === activeTab)?.component}</div>
|
<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>
|
||||||
<RadixDialog.Close asChild onClick={onClose}>
|
))}
|
||||||
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
|
</div>
|
||||||
</RadixDialog.Close>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</RadixDialog.Content>
|
</RadixDialog.Content>
|
||||||
|
</div>
|
||||||
</RadixDialog.Portal>
|
</RadixDialog.Portal>
|
||||||
</RadixDialog.Root>
|
</RadixDialog.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,151 +1,208 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import Cookies from 'js-cookie';
|
|
||||||
import { logStore } from '~/lib/stores/logs';
|
import { logStore } from '~/lib/stores/logs';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
interface GitHubUserResponse {
|
interface GitHubUserResponse {
|
||||||
login: string;
|
login: string;
|
||||||
id: number;
|
avatar_url: string;
|
||||||
[key: string]: any; // for other properties we don't explicitly need
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubConnection {
|
||||||
|
user: GitHubUserResponse | null;
|
||||||
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionsTab() {
|
export default function ConnectionsTab() {
|
||||||
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
|
const [connection, setConnection] = useState<GitHubConnection>({
|
||||||
const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
|
user: null,
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
token: '',
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
|
||||||
|
// Load saved connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if credentials exist and verify them
|
const savedConnection = localStorage.getItem('github_connection');
|
||||||
if (githubUsername && githubToken) {
|
|
||||||
verifyGitHubCredentials();
|
if (savedConnection) {
|
||||||
|
setConnection(JSON.parse(savedConnection));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const verifyGitHubCredentials = async () => {
|
const fetchGithubUser = async (token: string) => {
|
||||||
setIsVerifying(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsConnecting(true);
|
||||||
|
|
||||||
const response = await fetch('https://api.github.com/user', {
|
const response = await fetch('https://api.github.com/user', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${githubToken}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
|
throw new Error('Invalid token or unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as GitHubUserResponse;
|
const data = (await response.json()) as GitHubUserResponse;
|
||||||
|
const newConnection = { user: data, token };
|
||||||
|
|
||||||
if (data.login === githubUsername) {
|
// Save connection
|
||||||
setIsConnected(true);
|
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||||
return true;
|
setConnection(newConnection);
|
||||||
}
|
toast.success('Successfully connected to GitHub');
|
||||||
}
|
|
||||||
|
|
||||||
setIsConnected(false);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying GitHub credentials:', error);
|
logStore.logError('Failed to authenticate with GitHub', { error });
|
||||||
setIsConnected(false);
|
toast.error('Failed to connect to GitHub');
|
||||||
|
setConnection({ user: null, token: '' });
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifying(false);
|
setIsConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConnection = async () => {
|
const handleConnect = async (event: React.FormEvent) => {
|
||||||
if (!githubUsername || !githubToken) {
|
event.preventDefault();
|
||||||
toast.error('Please provide both GitHub username and token');
|
await fetchGithubUser(connection.token);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsVerifying(true);
|
|
||||||
|
|
||||||
const isValid = await verifyGitHubCredentials();
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
Cookies.set('githubUsername', githubUsername);
|
|
||||||
Cookies.set('githubToken', githubToken);
|
|
||||||
logStore.logSystem('GitHub connection settings updated', {
|
|
||||||
username: githubUsername,
|
|
||||||
hasToken: !!githubToken,
|
|
||||||
});
|
|
||||||
toast.success('GitHub credentials verified and saved successfully!');
|
|
||||||
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
|
|
||||||
setIsConnected(true);
|
|
||||||
} else {
|
|
||||||
toast.error('Invalid GitHub credentials. Please check your username and token.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
Cookies.remove('githubUsername');
|
localStorage.removeItem('github_connection');
|
||||||
Cookies.remove('githubToken');
|
setConnection({ user: null, token: '' });
|
||||||
Cookies.remove('git:github.com');
|
toast.success('Disconnected from GitHub');
|
||||||
setGithubUsername('');
|
|
||||||
setGithubToken('');
|
|
||||||
setIsConnected(false);
|
|
||||||
logStore.logSystem('GitHub connection removed');
|
|
||||||
toast.success('GitHub connection removed successfully!');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 mb-4 border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-3">
|
<div className="flex items-center justify-center p-4">
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">GitHub Connection</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex mb-4">
|
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||||
<div className="flex-1 mr-2">
|
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">GitHub Username:</label>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={githubUsername}
|
value={connection.user?.login || ''}
|
||||||
onChange={(e) => setGithubUsername(e.target.value)}
|
disabled={true}
|
||||||
disabled={isVerifying}
|
placeholder="Not connected"
|
||||||
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"
|
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-1">
|
|
||||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
|
<div>
|
||||||
|
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={githubToken}
|
value={connection.token}
|
||||||
onChange={(e) => setGithubToken(e.target.value)}
|
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||||
disabled={isVerifying}
|
disabled={isConnecting || !!connection.user}
|
||||||
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"
|
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>
|
</div>
|
||||||
<div className="flex mb-4 items-center">
|
|
||||||
{!isConnected ? (
|
<div className="flex items-center gap-3">
|
||||||
|
{!connection.user ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveConnection}
|
onClick={handleConnect}
|
||||||
disabled={isVerifying || !githubUsername || !githubToken}
|
disabled={isConnecting || !connection.token}
|
||||||
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"
|
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',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isVerifying ? (
|
{isConnecting ? (
|
||||||
<>
|
<>
|
||||||
<div className="i-ph:spinner animate-spin mr-2" />
|
<div className="i-ph:spinner-gap animate-spin" />
|
||||||
Verifying...
|
Connecting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Connect'
|
<>
|
||||||
|
<div className="i-ph:plug-charging w-4 h-4" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
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"
|
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
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isConnected && (
|
|
||||||
<span className="text-sm text-green-600 flex items-center">
|
{connection.user && (
|
||||||
<div className="i-ph:check-circle mr-1" />
|
<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
|
Connected to GitHub
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,388 +1,422 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useNavigate } from '@remix-run/react';
|
import { motion } from 'framer-motion';
|
||||||
import Cookies from 'js-cookie';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { db, deleteById, getAll, setMessages } from '~/lib/persistence';
|
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
|
||||||
import { logStore } from '~/lib/stores/logs';
|
import { db, getAll } from '~/lib/persistence';
|
||||||
import { classNames } from '~/utils/classNames';
|
|
||||||
import type { Message } from 'ai';
|
|
||||||
|
|
||||||
// List of supported providers that can have API keys
|
|
||||||
const API_KEY_PROVIDERS = [
|
|
||||||
'Anthropic',
|
|
||||||
'OpenAI',
|
|
||||||
'Google',
|
|
||||||
'Groq',
|
|
||||||
'HuggingFace',
|
|
||||||
'OpenRouter',
|
|
||||||
'Deepseek',
|
|
||||||
'Mistral',
|
|
||||||
'OpenAILike',
|
|
||||||
'Together',
|
|
||||||
'xAI',
|
|
||||||
'Perplexity',
|
|
||||||
'Cohere',
|
|
||||||
'AzureOpenAI',
|
|
||||||
'AmazonBedrock',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface ApiKeys {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DataTab() {
|
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 [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
|
||||||
const downloadAsJson = (data: any, filename: string) => {
|
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const url = URL.createObjectURL(blob);
|
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportAllChats = async () => {
|
const handleExportAllChats = async () => {
|
||||||
|
try {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
const error = new Error('Database is not available');
|
throw new Error('Database not initialized');
|
||||||
logStore.logError('Failed to export chats - DB unavailable', error);
|
|
||||||
toast.error('Database is not available');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Get all chats from IndexedDB
|
||||||
const allChats = await getAll(db);
|
const allChats = await getAll(db);
|
||||||
const exportData = {
|
const exportData = {
|
||||||
chats: allChats,
|
chats: allChats,
|
||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
|
// Download as JSON
|
||||||
logStore.logSystem('Chats exported successfully', { count: allChats.length });
|
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');
|
toast.success('Chats exported successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logStore.logError('Failed to export chats', error);
|
console.error('Export error:', error);
|
||||||
toast.error('Failed to export chats');
|
toast.error('Failed to export chats');
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAllChats = async () => {
|
|
||||||
const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
|
|
||||||
|
|
||||||
if (!confirmDelete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db) {
|
|
||||||
const error = new Error('Database is not available');
|
|
||||||
logStore.logError('Failed to delete chats - DB unavailable', error);
|
|
||||||
toast.error('Database is not available');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsDeleting(true);
|
|
||||||
|
|
||||||
const allChats = await getAll(db);
|
|
||||||
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
|
|
||||||
logStore.logSystem('All chats deleted successfully', { count: allChats.length });
|
|
||||||
toast.success('All chats deleted successfully');
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
} catch (error) {
|
|
||||||
logStore.logError('Failed to delete chats', error);
|
|
||||||
toast.error('Failed to delete chats');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportSettings = () => {
|
const handleExportSettings = () => {
|
||||||
|
try {
|
||||||
const settings = {
|
const settings = {
|
||||||
providers: Cookies.get('providers'),
|
userProfile: localStorage.getItem('bolt_user_profile'),
|
||||||
isDebugEnabled: Cookies.get('isDebugEnabled'),
|
settings: localStorage.getItem('bolt_settings'),
|
||||||
isEventLogsEnabled: Cookies.get('isEventLogsEnabled'),
|
exportDate: new Date().toISOString(),
|
||||||
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');
|
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');
|
toast.success('Settings exported successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error);
|
||||||
|
toast.error('Failed to export settings');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
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 {
|
try {
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
const data = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
const chatsToImport = processChatData(data);
|
|
||||||
|
|
||||||
for (const chat of chatsToImport) {
|
if (settings.userProfile) {
|
||||||
await setMessages(db, chat.id, chat.messages, chat.urlId, chat.description);
|
localStorage.setItem('bolt_user_profile', settings.userProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
logStore.logSystem('Chats imported successfully', { count: chatsToImport.length });
|
if (settings.settings) {
|
||||||
toast.success(`Successfully imported ${chatsToImport.length} chat${chatsToImport.length > 1 ? 's' : ''}`);
|
localStorage.setItem('bolt_settings', settings.settings);
|
||||||
window.location.reload();
|
}
|
||||||
|
|
||||||
|
window.location.reload(); // Reload to apply settings
|
||||||
|
toast.success('Settings imported successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
console.error('Import error:', error);
|
||||||
logStore.logError('Failed to import chats:', error);
|
toast.error('Failed to import settings');
|
||||||
toast.error('Failed to import chats: ' + error.message);
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to import chats');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.click();
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Data Management</h3>
|
{/* Reset Settings Dialog */}
|
||||||
<div className="space-y-8">
|
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
|
||||||
<div className="flex flex-col gap-4">
|
<Dialog showCloseButton={false}>
|
||||||
<div>
|
<div className="p-6">
|
||||||
<h4 className="text-bolt-elements-textPrimary mb-2">Chat History</h4>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">Export or delete all your chat history.</p>
|
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
|
||||||
<div className="flex gap-4">
|
<DialogTitle>Reset All Settings?</DialogTitle>
|
||||||
<button
|
</div>
|
||||||
onClick={handleExportAllChats}
|
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
||||||
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"
|
This will reset all your settings to their default values. This action cannot be undone.
|
||||||
>
|
</p>
|
||||||
Export All Chats
|
<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>
|
</button>
|
||||||
<button
|
</DialogClose>
|
||||||
onClick={handleImportChats}
|
<motion.button
|
||||||
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"
|
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 }}
|
||||||
>
|
>
|
||||||
Import Chats
|
{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>
|
</button>
|
||||||
<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}
|
onClick={handleDeleteAllChats}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className={classNames(
|
whileHover={{ scale: 1.02 }}
|
||||||
'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',
|
whileTap={{ scale: 0.98 }}
|
||||||
isDeleting ? 'opacity-50 cursor-not-allowed' : '',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
|
{isDeleting ? (
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
<div>
|
{/* Chat History Section */}
|
||||||
<h4 className="text-bolt-elements-textPrimary mb-2">Settings Backup</h4>
|
<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>
|
||||||
|
<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">
|
<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.
|
Export your settings to a JSON file or import settings from a previously exported file.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<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={handleExportSettings}
|
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"
|
|
||||||
>
|
>
|
||||||
|
<div className="i-ph:download-simple w-4 h-4" />
|
||||||
Export Settings
|
Export Settings
|
||||||
</button>
|
</motion.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">
|
<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
|
Import Settings
|
||||||
<input type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
</motion.button>
|
||||||
</label>
|
<motion.button
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div>
|
{/* API Keys Management Section */}
|
||||||
<h4 className="text-bolt-elements-textPrimary mb-2">API Keys Management</h4>
|
<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">
|
<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.
|
Import API keys from a JSON file or download a template to fill in your keys.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<input
|
||||||
onClick={handleExportApiKeyTemplate}
|
ref={apiKeyFileInputRef}
|
||||||
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"
|
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
|
Download Template
|
||||||
</button>
|
</motion.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">
|
<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
|
Import API Keys
|
||||||
<input type="file" accept=".json" onChange={handleImportApiKeys} className="hidden" />
|
</motion.button>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { useSettings } from '~/lib/hooks/useSettings';
|
import { useSettings } from '~/lib/hooks/useSettings';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { settingsStyles } from '~/components/settings/settings.styles';
|
||||||
|
|
||||||
interface ProviderStatus {
|
interface ProviderStatus {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -438,107 +441,182 @@ export default function DebugTab() {
|
|||||||
}, [activeProviders, systemInfo, isLatestBranch]);
|
}, [activeProviders, systemInfo, isLatestBranch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<motion.button
|
||||||
onClick={handleCopyToClipboard}
|
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
|
Copy Debug Info
|
||||||
</button>
|
</motion.button>
|
||||||
<button
|
<motion.button
|
||||||
onClick={handleCheckForUpdate}
|
onClick={handleCheckForUpdate}
|
||||||
disabled={isCheckingUpdate}
|
disabled={isCheckingUpdate}
|
||||||
className={`bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200
|
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
|
||||||
${!isCheckingUpdate ? 'hover:bg-bolt-elements-button-primary-backgroundHover' : 'opacity-75 cursor-not-allowed'}
|
whileHover={!isCheckingUpdate ? { scale: 1.02 } : undefined}
|
||||||
text-bolt-elements-button-primary-text`}
|
whileTap={!isCheckingUpdate ? { scale: 0.98 } : undefined}
|
||||||
>
|
>
|
||||||
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
|
{isCheckingUpdate ? (
|
||||||
</button>
|
<>
|
||||||
|
<div className={settingsStyles['loading-spinner']} />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:arrow-clockwise" />
|
||||||
|
Check for Updates
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updateMessage && (
|
{updateMessage && (
|
||||||
<div
|
<motion.div
|
||||||
className={`bg-bolt-elements-surface rounded-lg p-3 ${
|
className={classNames(
|
||||||
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-400' : ''
|
settingsStyles.card,
|
||||||
}`}
|
'bg-bolt-elements-background-depth-2',
|
||||||
|
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-500' : '',
|
||||||
|
)}
|
||||||
|
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>
|
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
|
||||||
{updateMessage.includes('Update available') && (
|
{updateMessage.includes('Update available') && (
|
||||||
<div className="mt-3 text-sm">
|
<div className="mt-3">
|
||||||
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
|
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
|
||||||
<ol className="list-decimal ml-4 mt-1 text-bolt-elements-textSecondary">
|
<ol className="list-decimal ml-4 mt-1 space-y-2">
|
||||||
<li>
|
<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:{' '}
|
Pull the latest changes:{' '}
|
||||||
<code className="bg-bolt-elements-surface-hover px-1 rounded">git pull upstream main</code>
|
<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>
|
||||||
<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:{' '}
|
Install any new dependencies:{' '}
|
||||||
<code className="bg-bolt-elements-surface-hover px-1 rounded">pnpm install</code>
|
<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>
|
</li>
|
||||||
<li>Restart the application</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<motion.div className="space-y-4">
|
||||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">System Information</h4>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div className="bg-bolt-elements-surface rounded-lg p-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 className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<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>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<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>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.deviceType}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Browser</p>
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Display</p>
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||||
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
|
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Connection</p>
|
||||||
<p className="text-sm font-medium flex items-center gap-2">
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span
|
<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'}
|
{systemInfo.online ? 'Online' : 'Offline'}
|
||||||
</span>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Language</p>
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Timezone</p>
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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>
|
<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>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.cores}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 pt-3 border-t border-bolt-elements-surface-hover">
|
<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>
|
<p className="text-xs text-bolt-elements-textSecondary">Version</p>
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
|
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
|
||||||
{connitJson.commit.slice(0, 7)}
|
{connitJson.commit.slice(0, 7)}
|
||||||
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
|
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
|
||||||
@@ -546,22 +624,31 @@ export default function DebugTab() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div>
|
<motion.div
|
||||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">Local LLM Status</h4>
|
className="space-y-4"
|
||||||
<div className="bg-bolt-elements-surface rounded-lg">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<div className="grid grid-cols-1 divide-y">
|
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) => (
|
{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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={classNames(
|
||||||
!provider.enabled ? 'bg-gray-300' : provider.isRunning ? 'bg-green-400' : 'bg-red-400'
|
'w-2 h-2 rounded-full',
|
||||||
}`}
|
!provider.enabled ? 'bg-gray-400' : provider.isRunning ? 'bg-green-500' : 'bg-red-500',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -575,17 +662,21 @@ export default function DebugTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
className={classNames(
|
||||||
provider.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
'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'}
|
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</span>
|
</span>
|
||||||
{provider.enabled && (
|
{provider.enabled && (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
className={classNames(
|
||||||
provider.isRunning ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
'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'}
|
{provider.isRunning ? 'Running' : 'Not Running'}
|
||||||
</span>
|
</span>
|
||||||
@@ -593,31 +684,28 @@ export default function DebugTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-5 flex flex-col space-y-1 text-xs">
|
<div className="pl-5 mt-2 space-y-2">
|
||||||
{/* Status Details */}
|
|
||||||
<div className="flex flex-wrap gap-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()}
|
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
{provider.responseTime && (
|
{provider.responseTime && (
|
||||||
<span className="text-bolt-elements-textSecondary">
|
<span className="text-xs text-bolt-elements-textSecondary">
|
||||||
Response time: {Math.round(provider.responseTime)}ms
|
Response time: {Math.round(provider.responseTime)}ms
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{provider.error && (
|
{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}
|
<span className="font-medium">Error:</span> {provider.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connection Info */}
|
|
||||||
{provider.url && (
|
{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>
|
<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} (root)</li>
|
||||||
<li>{provider.url}/api/health</li>
|
<li>{provider.url}/api/health</li>
|
||||||
<li>{provider.url}/v1/models</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 className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 { useSettings } from '~/lib/hooks/useSettings';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Switch } from '~/components/ui/Switch';
|
import { Switch } from '~/components/ui/Switch';
|
||||||
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { settingsStyles } from '~/components/settings/settings.styles';
|
||||||
|
|
||||||
export default function EventLogsTab() {
|
export default function EventLogsTab() {
|
||||||
const {} = useSettings();
|
const {} = useSettings();
|
||||||
const showLogs = useStore(logStore.showLogs);
|
const showLogs = useStore(logStore.showLogs);
|
||||||
|
const logs = useStore(logStore.logs);
|
||||||
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
|
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [, forceUpdate] = useState({});
|
const [, forceUpdate] = useState({});
|
||||||
|
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
|
||||||
|
|
||||||
const filteredLogs = useMemo(() => {
|
const filteredLogs = useMemo(() => {
|
||||||
const logs = logStore.getLogs();
|
const allLogs = Object.values(logs);
|
||||||
return logs.filter((log) => {
|
const filtered = allLogs.filter((log) => {
|
||||||
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
|
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!searchQuery ||
|
!searchQuery ||
|
||||||
@@ -25,7 +30,9 @@ export default function EventLogsTab() {
|
|||||||
|
|
||||||
return matchesLevel && matchesSearch;
|
return matchesLevel && matchesSearch;
|
||||||
});
|
});
|
||||||
}, [logLevel, searchQuery]);
|
|
||||||
|
return filtered.reverse();
|
||||||
|
}, [logs, logLevel, searchQuery]);
|
||||||
|
|
||||||
// Effect to initialize showLogs
|
// Effect to initialize showLogs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,18 +44,51 @@ export default function EventLogsTab() {
|
|||||||
logStore.logSystem('Application initialized', {
|
logStore.logSystem('Application initialized', {
|
||||||
version: process.env.NEXT_PUBLIC_APP_VERSION,
|
version: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||||
environment: process.env.NODE_ENV,
|
environment: process.env.NODE_ENV,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug logs for system state
|
// Debug logs for system state
|
||||||
logStore.logDebug('System configuration loaded', {
|
logStore.logDebug('System configuration loaded', {
|
||||||
runtime: 'Next.js',
|
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
|
// Warning logs for potential issues
|
||||||
logStore.logWarning('Resource usage threshold approaching', {
|
logStore.logWarning('Resource usage threshold approaching', {
|
||||||
memoryUsage: '75%',
|
memoryUsage: '75%',
|
||||||
cpuLoad: '60%',
|
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
|
// Error logs with detailed context
|
||||||
@@ -56,16 +96,50 @@ export default function EventLogsTab() {
|
|||||||
endpoint: '/api/chat',
|
endpoint: '/api/chat',
|
||||||
retryCount: 3,
|
retryCount: 3,
|
||||||
lastAttempt: new Date().toISOString(),
|
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(() => {
|
useEffect(() => {
|
||||||
const container = document.querySelector('.logs-container');
|
const container = logsContainerRef.current;
|
||||||
|
|
||||||
if (container && autoScroll) {
|
if (!container) {
|
||||||
container.scrollTop = container.scrollHeight;
|
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(() => {
|
const handleClearLogs = useCallback(() => {
|
||||||
if (confirm('Are you sure you want to clear all logs?')) {
|
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']) => {
|
const getLevelColor = (level: LogEntry['level']) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'info':
|
case 'info':
|
||||||
return 'text-blue-500';
|
return 'text-[#1389FD] dark:text-[#1389FD]';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'text-yellow-500';
|
return 'text-[#FFDB6C] dark:text-[#FFDB6C]';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'text-red-500';
|
return 'text-[#EE4744] dark:text-[#EE4744]';
|
||||||
case 'debug':
|
case 'debug':
|
||||||
return 'text-gray-500';
|
return 'text-[#77828D] dark:text-[#77828D]';
|
||||||
default:
|
default:
|
||||||
return 'text-bolt-elements-textPrimary';
|
return 'text-bolt-elements-textPrimary';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 h-full flex flex-col">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col space-y-4 mb-4">
|
<div className="flex flex-col space-y-4">
|
||||||
{/* Title and Toggles Row */}
|
{/* Title and Toggles Row */}
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<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>
|
<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 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>
|
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
|
||||||
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
|
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
|
||||||
</div>
|
</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>
|
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
|
||||||
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
|
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
|
||||||
</div>
|
</div>
|
||||||
@@ -137,83 +234,166 @@ export default function EventLogsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls Row */}
|
{/* Controls Row */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex-1 min-w-[150px] max-w-[200px]">
|
||||||
|
<div className="relative group">
|
||||||
<select
|
<select
|
||||||
value={logLevel}
|
value={logLevel}
|
||||||
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
|
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]"
|
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</option>
|
<option value="all">All Levels</option>
|
||||||
<option value="info">Info</option>
|
<option value="info">Info</option>
|
||||||
<option value="warning">Warning</option>
|
<option value="warning">Warning</option>
|
||||||
<option value="error">Error</option>
|
<option value="error">Error</option>
|
||||||
<option value="debug">Debug</option>
|
<option value="debug">Debug</option>
|
||||||
</select>
|
</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]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<div className="relative group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search logs..."
|
placeholder="Search logs..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<div className="flex items-center gap-2 flex-nowrap">
|
<div className="flex items-center gap-2 flex-nowrap">
|
||||||
<button
|
<motion.button
|
||||||
onClick={handleExportLogs}
|
onClick={handleExportLogs}
|
||||||
className={classNames(
|
className={classNames(settingsStyles.button.base, settingsStyles.button.primary, 'group')}
|
||||||
'bg-bolt-elements-button-primary-background',
|
whileHover={{ scale: 1.02 }}
|
||||||
'rounded-lg px-4 py-2 transition-colors duration-200',
|
whileTap={{ scale: 0.98 }}
|
||||||
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
|
||||||
'text-bolt-elements-button-primary-text',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
<div className="i-ph:download-simple group-hover:scale-110 transition-transform" />
|
||||||
Export Logs
|
Export Logs
|
||||||
</button>
|
</motion.button>
|
||||||
<button
|
<motion.button
|
||||||
onClick={handleClearLogs}
|
onClick={handleClearLogs}
|
||||||
className={classNames(
|
className={classNames(settingsStyles.button.base, settingsStyles.button.danger, 'group')}
|
||||||
'bg-bolt-elements-button-danger-background',
|
whileHover={{ scale: 1.02 }}
|
||||||
'rounded-lg px-4 py-2 transition-colors duration-200',
|
whileTap={{ scale: 0.98 }}
|
||||||
'hover:bg-bolt-elements-button-danger-backgroundHover',
|
|
||||||
'text-bolt-elements-button-danger-text',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
<div className="i-ph:trash group-hover:scale-110 transition-transform" />
|
||||||
Clear Logs
|
Clear Logs
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<motion.div
|
||||||
{filteredLogs.length === 0 ? (
|
ref={logsContainerRef}
|
||||||
<div className="text-center text-bolt-elements-textSecondary py-8">No logs found</div>
|
className={classNames(
|
||||||
) : (
|
settingsStyles.card,
|
||||||
filteredLogs.map((log, index) => (
|
'h-[calc(100vh-250px)] min-h-[400px] overflow-y-auto logs-container',
|
||||||
<div
|
'scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-purple-500/30',
|
||||||
key={index}
|
)}
|
||||||
className="text-sm mb-3 font-mono border-b border-bolt-elements-borderColor pb-2 last:border-0"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-2 flex-wrap">
|
{filteredLogs.length === 0 ? (
|
||||||
<span className={`font-bold ${getLevelColor(log.level)} whitespace-nowrap`}>
|
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||||
[{log.level.toUpperCase()}]
|
<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>
|
||||||
<span className="text-bolt-elements-textSecondary whitespace-nowrap">
|
<span className="text-bolt-elements-textSecondary whitespace-nowrap text-xs">
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
|
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{log.details && (
|
{log.details && (
|
||||||
<pre className="mt-2 text-xs text-bolt-elements-textSecondary overflow-x-auto whitespace-pre-wrap break-all">
|
<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)}
|
{JSON.stringify(log.details, null, 2)}
|
||||||
</pre>
|
</motion.pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Switch } from '~/components/ui/Switch';
|
import { Switch } from '~/components/ui/Switch';
|
||||||
import { PromptLibrary } from '~/lib/common/prompt-library';
|
import { PromptLibrary } from '~/lib/common/prompt-library';
|
||||||
import { useSettings } from '~/lib/hooks/useSettings';
|
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() {
|
export default function FeaturesTab() {
|
||||||
const {
|
const {
|
||||||
@@ -20,79 +35,255 @@ export default function FeaturesTab() {
|
|||||||
contextOptimizationEnabled,
|
contextOptimizationEnabled,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
|
|
||||||
|
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
|
||||||
|
const [expandedFeature, setExpandedFeature] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleToggle = (enabled: boolean) => {
|
const handleToggle = (enabled: boolean) => {
|
||||||
enableDebugMode(enabled);
|
enableDebugMode(enabled);
|
||||||
enableEventLogs(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 (
|
return (
|
||||||
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
<motion.div
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Optional Features</h3>
|
className="flex items-center gap-2"
|
||||||
<div className="space-y-4">
|
initial={{ opacity: 0, y: -20 }}
|
||||||
<div className="flex items-center justify-between">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<span className="text-bolt-elements-textPrimary">Debug Features</span>
|
transition={{ duration: 0.3 }}
|
||||||
<Switch className="ml-auto" checked={debug} onCheckedChange={handleToggle} />
|
>
|
||||||
</div>
|
<div className="i-ph:puzzle-piece text-xl text-purple-500" />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-bolt-elements-textPrimary">Use Main Branch</span>
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Features</h3>
|
||||||
<p className="text-xs text-bolt-elements-textTertiary">
|
<p className="text-sm text-bolt-elements-textSecondary">Customize your Bolt experience</p>
|
||||||
Check for updates against the main branch instead of stable
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch className="ml-auto" checked={isLatestBranch} onCheckedChange={enableLatestBranch} />
|
</motion.div>
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
|
<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>
|
<div>
|
||||||
<span className="text-bolt-elements-textPrimary">Auto Select Code Template</span>
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
||||||
<p className="text-xs text-bolt-elements-textTertiary">
|
{feature.title}
|
||||||
Let Bolt select the best starter template for your project.
|
</h4>
|
||||||
</p>
|
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{feature.description}</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>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
className="ml-auto"
|
checked={feature.enabled}
|
||||||
checked={contextOptimizationEnabled}
|
onCheckedChange={(checked) => handleToggleFeature(feature.id, checked)}
|
||||||
onCheckedChange={enableContextOptimization}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 border-t border-bolt-elements-borderColor pt-4">
|
<motion.div
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Experimental Features</h3>
|
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
||||||
<p className="text-sm text-bolt-elements-textSecondary mb-10">
|
animate={{
|
||||||
Disclaimer: Experimental features may be unstable and are subject to change.
|
borderColor: feature.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
||||||
</p>
|
scale: feature.enabled ? 1 : 0.98,
|
||||||
<div className="flex flex-col">
|
}}
|
||||||
<div className="flex items-center justify-between mb-2">
|
transition={{ duration: 0.2 }}
|
||||||
<span className="text-bolt-elements-textPrimary">Experimental Providers</span>
|
/>
|
||||||
<Switch className="ml-auto" checked={isLocalModel} onCheckedChange={enableLocalModels} />
|
</motion.div>
|
||||||
</div>
|
))}
|
||||||
<p className="text-xs text-bolt-elements-textTertiary mb-4">
|
</motion.div>
|
||||||
Enable experimental providers such as Ollama, LMStudio, and OpenAILike.
|
|
||||||
</p>
|
<motion.div
|
||||||
</div>
|
className={classNames(
|
||||||
<div className="flex items-start justify-between pt-4 mb-2 gap-2">
|
settingsStyles.card,
|
||||||
<div className="flex-1 max-w-[200px]">
|
'bg-bolt-elements-background-depth-2',
|
||||||
<span className="text-bolt-elements-textPrimary">Prompt Library</span>
|
'hover:bg-bolt-elements-background-depth-3',
|
||||||
<p className="text-xs text-bolt-elements-textTertiary mb-4">
|
'transition-all duration-200',
|
||||||
Choose a prompt from the library to use as the system prompt.
|
'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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={promptId}
|
value={promptId}
|
||||||
onChange={(e) => setPromptId(e.target.value)}
|
onChange={(e) => {
|
||||||
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]"
|
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) => (
|
{PromptLibrary.getList().map((x) => (
|
||||||
<option key={x.id} value={x.id}>
|
<option key={x.id} value={x.id}>
|
||||||
@@ -103,5 +294,7 @@ export default function FeaturesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</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 { Switch } from '~/components/ui/Switch';
|
||||||
|
import Separator from '~/components/ui/Separator';
|
||||||
import { useSettings } from '~/lib/hooks/useSettings';
|
import { useSettings } from '~/lib/hooks/useSettings';
|
||||||
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
||||||
import type { IProviderConfig } from '~/types/model';
|
import type { IProviderConfig } from '~/types/model';
|
||||||
import { logStore } from '~/lib/stores/logs';
|
import { logStore } from '~/lib/stores/logs';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
// Import a default fallback icon
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { settingsStyles } from '~/components/settings/settings.styles';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
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() {
|
export default function ProvidersTab() {
|
||||||
const { providers, updateProviderSettings, isLocalModel } = useSettings();
|
const { providers, updateProviderSettings, isLocalModel } = useSettings();
|
||||||
|
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||||
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
||||||
|
const [categoryEnabled, setCategoryEnabled] = useState<CategoryToggleState>({
|
||||||
|
cloud: false,
|
||||||
|
local: false,
|
||||||
|
});
|
||||||
|
const [showOllamaUpdater, setShowOllamaUpdater] = useState(false);
|
||||||
|
|
||||||
// Load base URLs from cookies
|
// Group providers by category
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
|
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
|
||||||
...value,
|
...value,
|
||||||
name: key,
|
name: key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (searchTerm && searchTerm.length > 0) {
|
|
||||||
newFilteredProviders = newFilteredProviders.filter((provider) =>
|
|
||||||
provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLocalModel) {
|
if (!isLocalModel) {
|
||||||
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
|
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
|
||||||
}
|
}
|
||||||
@@ -40,59 +163,22 @@ export default function ProvidersTab() {
|
|||||||
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
||||||
|
|
||||||
setFilteredProviders([...regular, ...urlConfigurable]);
|
setFilteredProviders([...regular, ...urlConfigurable]);
|
||||||
}, [providers, searchTerm, isLocalModel]);
|
}, [providers, isLocalModel]);
|
||||||
|
|
||||||
const renderProviderCard = (provider: IProviderConfig) => {
|
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
||||||
const envBaseUrlKey = providerBaseUrlEnvKeys[provider.name].baseUrlKey;
|
|
||||||
const envBaseUrl = envBaseUrlKey ? import.meta.env[envBaseUrlKey] : undefined;
|
|
||||||
const isUrlConfigurable = URL_CONFIGURABLE_PROVIDERS.includes(provider.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={provider.name}
|
|
||||||
className="flex flex-col provider-item hover:bg-bolt-elements-bg-depth-3 p-4 rounded-lg border border-bolt-elements-borderColor"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
src={`/icons/${provider.name}.svg`}
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = DefaultIcon;
|
|
||||||
}}
|
|
||||||
alt={`${provider.name} icon`}
|
|
||||||
className="w-6 h-6 dark:invert"
|
|
||||||
/>
|
|
||||||
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
className="ml-auto"
|
|
||||||
checked={provider.settings.enabled}
|
|
||||||
onCheckedChange={(enabled) => {
|
|
||||||
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
||||||
|
toast.success(`${provider.name} enabled`);
|
||||||
} else {
|
} else {
|
||||||
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
||||||
|
toast.success(`${provider.name} disabled`);
|
||||||
}
|
}
|
||||||
}}
|
};
|
||||||
/>
|
|
||||||
</div>
|
const handleUpdateBaseUrl = (provider: IProviderConfig, baseUrl: string) => {
|
||||||
{isUrlConfigurable && provider.settings.enabled && (
|
let newBaseUrl: string | undefined = baseUrl;
|
||||||
<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) {
|
if (newBaseUrl && newBaseUrl.trim().length === 0) {
|
||||||
newBaseUrl = undefined;
|
newBaseUrl = undefined;
|
||||||
@@ -103,45 +189,219 @@ export default function ProvidersTab() {
|
|||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
baseUrl: newBaseUrl,
|
baseUrl: newBaseUrl,
|
||||||
});
|
});
|
||||||
}}
|
toast.success(`${provider.name} base URL updated`);
|
||||||
placeholder={`Enter ${provider.name} base URL`}
|
setEditingProvider(null);
|
||||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
|
||||||
const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="space-y-6">
|
||||||
<div className="flex mb-4">
|
{Object.entries(groupedProviders).map(([category, group]) => (
|
||||||
<input
|
<motion.div
|
||||||
type="text"
|
key={category}
|
||||||
placeholder="Search providers..."
|
className="space-y-4"
|
||||||
value={searchTerm}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
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"
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Regular Providers Grid */}
|
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
||||||
<div className="grid grid-cols-2 gap-4 mb-8">{regularProviders.map(renderProviderCard)}</div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
{/* URL Configurable Providers Section */}
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
{urlConfigurableProviders.length > 0 && (
|
exit={{ opacity: 0, height: 0 }}
|
||||||
<div className="mt-8">
|
transition={{ duration: 0.2 }}
|
||||||
<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">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
These providers are experimental and allow you to run AI models locally or connect to your own
|
{editingProvider === provider.name ? (
|
||||||
infrastructure. They require additional setup but offer more flexibility.
|
<input
|
||||||
</p>
|
type="text"
|
||||||
<div className="space-y-4">{urlConfigurableProviders.map(renderProviderCard)}</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</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';
|
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 = {
|
const transition = {
|
||||||
duration: 0.15,
|
duration: 0.15,
|
||||||
ease: cubicEasingFn,
|
ease: cubicEasingFn,
|
||||||
@@ -40,81 +85,39 @@ export const dialogVariants = {
|
|||||||
},
|
},
|
||||||
} satisfies Variants;
|
} 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 {
|
interface DialogProps {
|
||||||
children: ReactNode | ReactNode[];
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
onBackdrop?: (event: React.UIEvent) => void;
|
showCloseButton?: boolean;
|
||||||
onClose?: (event: React.UIEvent) => void;
|
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 (
|
return (
|
||||||
<RadixDialog.Portal>
|
<RadixDialog.Portal>
|
||||||
<RadixDialog.Overlay onClick={onBackdrop} asChild>
|
<RadixDialog.Overlay asChild>
|
||||||
<motion.div
|
<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"
|
initial="closed"
|
||||||
animate="open"
|
animate="open"
|
||||||
exit="closed"
|
exit="closed"
|
||||||
variants={dialogBackdropVariants}
|
variants={dialogBackdropVariants}
|
||||||
|
onClick={onBackdrop}
|
||||||
/>
|
/>
|
||||||
</RadixDialog.Overlay>
|
</RadixDialog.Overlay>
|
||||||
<RadixDialog.Content asChild>
|
<RadixDialog.Content asChild>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={classNames(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
initial="closed"
|
initial="closed"
|
||||||
@@ -122,10 +125,17 @@ export const Dialog = memo(({ className, children, onBackdrop, onClose }: Dialog
|
|||||||
exit="closed"
|
exit="closed"
|
||||||
variants={dialogVariants}
|
variants={dialogVariants}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
{children}
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
<RadixDialog.Close asChild onClick={onClose}>
|
<RadixDialog.Close asChild onClick={onClose}>
|
||||||
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
|
<IconButton
|
||||||
|
icon="i-ph:x"
|
||||||
|
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||||
|
/>
|
||||||
</RadixDialog.Close>
|
</RadixDialog.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</RadixDialog.Content>
|
</RadixDialog.Content>
|
||||||
</RadixDialog.Portal>
|
</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();
|
this._loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose the logs store for subscription
|
||||||
|
get logs() {
|
||||||
|
return this._logs;
|
||||||
|
}
|
||||||
|
|
||||||
private _loadLogs() {
|
private _loadLogs() {
|
||||||
const savedLogs = Cookies.get('eventLogs');
|
const savedLogs = Cookies.get('eventLogs');
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
"node": ">=18.18.0"
|
"node": ">=18.18.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/amazon-bedrock": "1.0.6",
|
||||||
"@ai-sdk/anthropic": "^0.0.39",
|
"@ai-sdk/anthropic": "^0.0.39",
|
||||||
"@ai-sdk/cohere": "^1.0.3",
|
"@ai-sdk/cohere": "^1.0.3",
|
||||||
"@ai-sdk/google": "^0.0.52",
|
"@ai-sdk/google": "^0.0.52",
|
||||||
"@ai-sdk/mistral": "^0.0.43",
|
"@ai-sdk/mistral": "^0.0.43",
|
||||||
"@ai-sdk/openai": "^0.0.66",
|
"@ai-sdk/openai": "^0.0.66",
|
||||||
"@ai-sdk/amazon-bedrock": "1.0.6",
|
|
||||||
"@codemirror/autocomplete": "^6.18.3",
|
"@codemirror/autocomplete": "^6.18.3",
|
||||||
"@codemirror/commands": "^6.7.1",
|
"@codemirror/commands": "^6.7.1",
|
||||||
"@codemirror/lang-cpp": "^6.0.2",
|
"@codemirror/lang-cpp": "^6.0.2",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"@codemirror/search": "^6.5.8",
|
"@codemirror/search": "^6.5.8",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.35.0",
|
"@codemirror/view": "^6.35.0",
|
||||||
"@iconify-json/ph": "^1.2.1",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@iconify-json/svg-spinners": "^1.2.1",
|
"@iconify-json/svg-spinners": "^1.2.1",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ai": "^4.0.13",
|
"ai": "^4.0.13",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"diff": "^5.2.0",
|
"diff": "^5.2.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hotkeys-hook": "^4.6.1",
|
"react-hotkeys-hook": "^4.6.1",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"react-toastify": "^10.0.6",
|
"react-toastify": "^10.0.6",
|
||||||
@@ -102,11 +104,14 @@
|
|||||||
"remix-island": "^0.2.0",
|
"remix-island": "^0.2.0",
|
||||||
"remix-utils": "^7.7.0",
|
"remix-utils": "^7.7.0",
|
||||||
"shiki": "^1.24.0",
|
"shiki": "^1.24.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blitz/eslint-plugin": "0.1.0",
|
"@blitz/eslint-plugin": "0.1.0",
|
||||||
"@cloudflare/workers-types": "^4.20241127.0",
|
"@cloudflare/workers-types": "^4.20241127.0",
|
||||||
|
"@iconify-json/ph": "^1.2.1",
|
||||||
|
"@iconify/types": "^2.0.0",
|
||||||
"@remix-run/dev": "^2.15.0",
|
"@remix-run/dev": "^2.15.0",
|
||||||
"@types/diff": "^5.2.3",
|
"@types/diff": "^5.2.3",
|
||||||
"@types/dom-speech-recognition": "^0.0.4",
|
"@types/dom-speech-recognition": "^0.0.4",
|
||||||
|
|||||||
171
pnpm-lock.yaml
generated
171
pnpm-lock.yaml
generated
@@ -77,9 +77,9 @@ importers:
|
|||||||
'@codemirror/view':
|
'@codemirror/view':
|
||||||
specifier: ^6.35.0
|
specifier: ^6.35.0
|
||||||
version: 6.35.0
|
version: 6.35.0
|
||||||
'@iconify-json/ph':
|
'@headlessui/react':
|
||||||
specifier: ^1.2.1
|
specifier: ^2.2.0
|
||||||
version: 1.2.1
|
version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@iconify-json/svg-spinners':
|
'@iconify-json/svg-spinners':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
@@ -149,6 +149,9 @@ importers:
|
|||||||
chalk:
|
chalk:
|
||||||
specifier: ^5.4.1
|
specifier: ^5.4.1
|
||||||
version: 5.4.1
|
version: 5.4.1
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -200,6 +203,9 @@ importers:
|
|||||||
react-hotkeys-hook:
|
react-hotkeys-hook:
|
||||||
specifier: ^4.6.1
|
specifier: ^4.6.1
|
||||||
version: 4.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.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:
|
react-markdown:
|
||||||
specifier: ^9.0.1
|
specifier: ^9.0.1
|
||||||
version: 9.0.1(@types/react@18.3.12)(react@18.3.1)
|
version: 9.0.1(@types/react@18.3.12)(react@18.3.1)
|
||||||
@@ -227,6 +233,9 @@ importers:
|
|||||||
shiki:
|
shiki:
|
||||||
specifier: ^1.24.0
|
specifier: ^1.24.0
|
||||||
version: 1.24.0
|
version: 1.24.0
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^2.6.0
|
||||||
|
version: 2.6.0
|
||||||
unist-util-visit:
|
unist-util-visit:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
@@ -237,6 +246,12 @@ importers:
|
|||||||
'@cloudflare/workers-types':
|
'@cloudflare/workers-types':
|
||||||
specifier: ^4.20241127.0
|
specifier: ^4.20241127.0
|
||||||
version: 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':
|
'@remix-run/dev':
|
||||||
specifier: ^2.15.0
|
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))
|
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: '>=16.8.0'
|
||||||
react-dom: '>=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':
|
'@floating-ui/utils@0.2.8':
|
||||||
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -1997,6 +2025,40 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.0':
|
'@radix-ui/rect@1.1.0':
|
||||||
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
|
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':
|
'@remix-run/cloudflare-pages@2.15.0':
|
||||||
resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==}
|
resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -2398,6 +2460,18 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=8.40.0'
|
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':
|
'@types/acorn@4.0.6':
|
||||||
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
||||||
|
|
||||||
@@ -4965,6 +5039,11 @@ packages:
|
|||||||
react: '>=16.8.1'
|
react: '>=16.8.1'
|
||||||
react-dom: '>=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:
|
react-markdown@9.0.1:
|
||||||
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
|
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5559,6 +5638,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
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:
|
tar-fs@2.1.1:
|
||||||
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
|
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
|
||||||
|
|
||||||
@@ -7372,8 +7457,25 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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': {}
|
'@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/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@@ -7980,6 +8082,49 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.0': {}
|
'@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)':
|
'@remix-run/cloudflare-pages@2.15.0(@cloudflare/workers-types@4.20241127.0)(typescript@5.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/workers-types': 4.20241127.0
|
'@cloudflare/workers-types': 4.20241127.0
|
||||||
@@ -8543,6 +8688,18 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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':
|
'@types/acorn@4.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.6
|
||||||
@@ -11823,6 +11980,10 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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):
|
react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -12456,6 +12617,10 @@ snapshots:
|
|||||||
'@pkgr/core': 0.1.1
|
'@pkgr/core': 0.1.1
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
tabbable@6.2.0: {}
|
||||||
|
|
||||||
|
tailwind-merge@2.6.0: {}
|
||||||
|
|
||||||
tar-fs@2.1.1:
|
tar-fs@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
chownr: 1.1.4
|
chownr: 1.1.4
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
import { globSync } from 'fast-glob';
|
import { globSync } from 'fast-glob';
|
||||||
import fs from 'node:fs/promises';
|
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 { 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 collectionName = 'bolt';
|
||||||
|
|
||||||
const customIconCollection = iconPaths.reduce(
|
const customIconCollection = {
|
||||||
|
[collectionName]: iconPaths.reduce(
|
||||||
(acc, iconPath) => {
|
(acc, iconPath) => {
|
||||||
const [iconName] = basename(iconPath).split('.');
|
const [iconName] = basename(iconPath).split('.');
|
||||||
|
|
||||||
acc[collectionName] ??= {};
|
acc[iconName] = async () => {
|
||||||
acc[collectionName][iconName] = async () => fs.readFile(iconPath, 'utf8');
|
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;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, Record<string, () => Promise<string>>>,
|
{} as Record<string, () => Promise<string>>,
|
||||||
);
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const BASE_COLORS = {
|
const BASE_COLORS = {
|
||||||
white: '#FFFFFF',
|
white: '#FFFFFF',
|
||||||
@@ -98,9 +118,7 @@ const COLOR_PRIMITIVES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
safelist: [
|
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
||||||
...Object.keys(customIconCollection[collectionName]||{}).map(x=>`i-bolt:${x}`)
|
|
||||||
],
|
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'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',
|
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
||||||
@@ -242,9 +260,27 @@ export default defineConfig({
|
|||||||
presetIcons({
|
presetIcons({
|
||||||
warn: true,
|
warn: true,
|
||||||
collections: {
|
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',
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user