feat: bolt dyi datatab (#1570)

* Update DataTab.tsx

## API Key Import Fix

We identified and fixed an issue with the API key import functionality in the DataTab component. The problem was that API keys were being stored in localStorage instead of cookies, and the key format was being incorrectly processed.

### Changes Made:

1. **Updated `handleImportAPIKeys` function**:
   - Changed to store API keys in cookies instead of localStorage
   - Modified to use provider names directly as keys (e.g., "OpenAI", "Google")
   - Added logic to skip comment fields (keys starting with "_")
   - Added page reload after successful import to apply changes immediately

2. **Updated `handleDownloadTemplate` function**:
   - Changed template format to use provider names as keys
   - Added explanatory comment in the template
   - Removed URL-related keys that weren't being used properly

3. **Fixed template format**:
   - Template now uses the correct format with provider names as keys
   - Added support for all available providers including Hyperbolic

These changes ensure that when users download the template, fill it with their API keys, and import it back, the keys are properly stored in cookies with the correct format that the application expects.

* backwards compatible old import template

* Update the export / import settings

Settings Export/Import Improvements
We've completely redesigned the settings export and import functionality to ensure all application settings are properly backed up and restored:
Key Improvements
Comprehensive Export Format: Now captures ALL settings from both localStorage and cookies, organized into logical categories (core, providers, features, UI, connections, debug, updates)
Robust Import System: Automatically detects format version and handles both new and legacy formats with detailed error handling
Complete Settings Coverage: Properly exports and imports settings from ALL tabs including:
Local provider configurations (Ollama, LMStudio, etc.)
Cloud provider API keys (OpenAI, Anthropic, etc.)
Feature toggles and preferences
UI configurations and tab settings
Connection settings (GitHub, Netlify)
Debug configurations and logs
Technical Details
Added version tracking to export files for better compatibility
Implemented fallback mechanisms if primary import methods fail
Added detailed logging for troubleshooting import/export issues
Created helper functions for safer data handling
Maintained backward compatibility with older export formats

Feature Settings:
Feature flags and viewed features
Developer mode settings
Energy saver mode configurations
User Preferences:
User profile information
Theme settings
Tab configurations
Connection Settings:
Netlify connections
Git authentication credentials
Any other service connections
Debug and System Settings:
Debug flags and acknowledged issues
Error logs and event logs
Update settings and preferences

* Update DataTab.tsx

* Update GithubConnection.tsx

revert the code back as asked

* feat: enhance style to match the project

* feat:small improvements

* feat: add major improvements

* Update Dialog.tsx

* Delete DataTab.tsx.bak

* feat: small updates

* Update DataVisualization.tsx

* feat: dark mode fix
This commit is contained in:
Stijnus 2025-03-29 20:43:07 +01:00 committed by GitHub
parent 47444970e8
commit b86fd63700
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 6698 additions and 3940 deletions

7
.gitignore vendored
View File

@ -44,11 +44,4 @@ changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
*.md
<<<<<<< Updated upstream
=======
.qodo
exponent.txt
<<<<<<< Updated upstream
>>>>>>> Stashed changes
=======
>>>>>>> Stashed changes

View File

@ -29,7 +29,7 @@ import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
import DataTab from '~/components/@settings/tabs/data/DataTab';
import { DataTab } from '~/components/@settings/tabs/data/DataTab';
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
@ -416,7 +416,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -342,24 +342,86 @@ export default function DebugTab() {
try {
setLoading((prev) => ({ ...prev, systemInfo: true }));
// Get browser info
const ua = navigator.userAgent;
const browserName = ua.includes('Firefox')
? 'Firefox'
: ua.includes('Chrome')
? 'Chrome'
: ua.includes('Safari')
? 'Safari'
: ua.includes('Edge')
? 'Edge'
: 'Unknown';
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
// Get better OS detection
const userAgent = navigator.userAgent;
let detectedOS = 'Unknown';
let detectedArch = 'unknown';
// Improved OS detection
if (userAgent.indexOf('Win') !== -1) {
detectedOS = 'Windows';
} else if (userAgent.indexOf('Mac') !== -1) {
detectedOS = 'macOS';
} else if (userAgent.indexOf('Linux') !== -1) {
detectedOS = 'Linux';
} else if (userAgent.indexOf('Android') !== -1) {
detectedOS = 'Android';
} else if (/iPhone|iPad|iPod/.test(userAgent)) {
detectedOS = 'iOS';
}
// Better architecture detection
if (userAgent.indexOf('x86_64') !== -1 || userAgent.indexOf('x64') !== -1 || userAgent.indexOf('WOW64') !== -1) {
detectedArch = 'x64';
} else if (userAgent.indexOf('x86') !== -1 || userAgent.indexOf('i686') !== -1) {
detectedArch = 'x86';
} else if (userAgent.indexOf('arm64') !== -1 || userAgent.indexOf('aarch64') !== -1) {
detectedArch = 'arm64';
} else if (userAgent.indexOf('arm') !== -1) {
detectedArch = 'arm';
}
// Get browser info with improved detection
const browserName = (() => {
if (userAgent.indexOf('Edge') !== -1 || userAgent.indexOf('Edg/') !== -1) {
return 'Edge';
}
if (userAgent.indexOf('Chrome') !== -1) {
return 'Chrome';
}
if (userAgent.indexOf('Firefox') !== -1) {
return 'Firefox';
}
if (userAgent.indexOf('Safari') !== -1) {
return 'Safari';
}
return 'Unknown';
})();
const browserVersionMatch = userAgent.match(/(Edge|Edg|Chrome|Firefox|Safari)[\s/](\d+(\.\d+)*)/);
const browserVersion = browserVersionMatch ? browserVersionMatch[2] : 'Unknown';
// Get performance metrics
const memory = (performance as any).memory || {};
const timing = performance.timing;
const navigation = performance.navigation;
const connection = (navigator as any).connection;
const connection = (navigator as any).connection || {};
// Try to use Navigation Timing API Level 2 when available
let loadTime = 0;
let domReadyTime = 0;
try {
const navEntries = performance.getEntriesByType('navigation');
if (navEntries.length > 0) {
const navTiming = navEntries[0] as PerformanceNavigationTiming;
loadTime = navTiming.loadEventEnd - navTiming.startTime;
domReadyTime = navTiming.domContentLoadedEventEnd - navTiming.startTime;
} else {
// Fall back to older API
loadTime = timing.loadEventEnd - timing.navigationStart;
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
}
} catch {
// Fall back to older API if Navigation Timing API Level 2 is not available
loadTime = timing.loadEventEnd - timing.navigationStart;
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
}
// Get battery info
let batteryInfo;
@ -405,9 +467,9 @@ export default function DebugTab() {
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
const systemInfo: SystemInfo = {
os: navigator.platform,
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
platform: navigator.platform,
os: detectedOS,
arch: detectedArch,
platform: navigator.platform || 'unknown',
cpus: navigator.hardwareConcurrency + ' cores',
memory: {
total: formatBytes(totalMemory),
@ -423,7 +485,7 @@ export default function DebugTab() {
userAgent: navigator.userAgent,
cookiesEnabled: navigator.cookieEnabled,
online: navigator.onLine,
platform: navigator.platform,
platform: navigator.platform || 'unknown',
cores: navigator.hardwareConcurrency,
},
screen: {
@ -445,8 +507,8 @@ export default function DebugTab() {
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
},
timing: {
loadTime: timing.loadEventEnd - timing.navigationStart,
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
loadTime,
domReadyTime,
readyStart: timing.fetchStart - timing.navigationStart,
redirectTime: timing.redirectEnd - timing.redirectStart,
appcacheTime: timing.domainLookupStart - timing.fetchStart,
@ -483,6 +545,23 @@ export default function DebugTab() {
}
};
// Helper function to format bytes to human readable format with better precision
const formatBytes = (bytes: number) => {
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
// Return with proper precision based on unit size
if (i === 0) {
return `${bytes} ${units[i]}`;
}
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
};
const getWebAppInfo = async () => {
try {
setLoading((prev) => ({ ...prev, webAppInfo: true }));
@ -520,20 +599,6 @@ export default function DebugTab() {
}
};
// Helper function to format bytes to human readable format
const formatBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${Math.round(size)} ${units[unitIndex]}`;
};
const handleLogPerformance = () => {
try {
setLoading((prev) => ({ ...prev, performance: true }));
@ -1353,9 +1418,7 @@ export default function DebugTab() {
</div>
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
DOM Ready: {systemInfo
? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)
: '-'}s
DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
</div>
</div>

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { classNames } from '~/utils/classNames';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={classNames(
'peer h-4 w-4 shrink-0 rounded-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:focus:ring-purple-400 dark:focus:ring-offset-gray-900',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-3 w-3 text-purple-500 dark:text-purple-400" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = 'Checkbox';
export { Checkbox };

View File

@ -1,9 +1,13 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, type Variants } from 'framer-motion';
import React, { memo, type ReactNode } from 'react';
import React, { memo, type ReactNode, useState, useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { IconButton } from './IconButton';
import { Button } from './Button';
import { FixedSizeList } from 'react-window';
import { Checkbox } from './Checkbox';
import { Label } from './Label';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
@ -17,12 +21,14 @@ interface DialogButtonProps {
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
return (
<button
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors', {
'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600': type === 'primary',
'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100':
type === 'secondary',
'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger',
})}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
type === 'primary'
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
: type === 'secondary'
? 'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
: 'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
)}
onClick={onClick}
disabled={disabled}
>
@ -34,7 +40,7 @@ export const DialogButton = memo(({ type, children, onClick, disabled }: DialogB
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)}
className={classNames('text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-2', className)}
{...props}
>
{children}
@ -45,7 +51,7 @@ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('text-sm text-bolt-elements-textSecondary', 'mt-1', className)}
className={classNames('text-sm text-bolt-elements-textSecondary mt-1', className)}
{...props}
>
{children}
@ -99,11 +105,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames(
'fixed inset-0 z-[9999]',
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
'backdrop-blur-[2px]',
)}
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
@ -114,11 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'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]',
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px]',
className,
)}
initial="closed"
@ -132,7 +130,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
className="absolute top-3 right-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
/>
</RadixDialog.Close>
)}
@ -142,3 +140,310 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
</RadixDialog.Portal>
);
});
/**
* Props for the ConfirmationDialog component
*/
export interface ConfirmationDialogProps {
/**
* Whether the dialog is open
*/
isOpen: boolean;
/**
* Callback when the dialog is closed
*/
onClose: () => void;
/**
* Callback when the confirm button is clicked
*/
onConfirm: () => void;
/**
* The title of the dialog
*/
title: string;
/**
* The description of the dialog
*/
description: string;
/**
* The text for the confirm button
*/
confirmLabel?: string;
/**
* The text for the cancel button
*/
cancelLabel?: string;
/**
* The variant of the confirm button
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
/**
* Whether the confirm button is in a loading state
*/
isLoading?: boolean;
}
/**
* A reusable confirmation dialog component that uses the Dialog component
*/
export function ConfirmationDialog({
isOpen,
onClose,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
isLoading = false,
onConfirm,
}: ConfirmationDialogProps) {
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog showCloseButton={false}>
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mb-4">{description}</DialogDescription>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{cancelLabel}
</Button>
<Button
variant={variant}
onClick={onConfirm}
disabled={isLoading}
className={
variant === 'destructive'
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent hover:bg-bolt-elements-button-primary-backgroundHover'
}
>
{isLoading ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
{confirmLabel}
</>
) : (
confirmLabel
)}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
}
/**
* Type for selection item in SelectionDialog
*/
type SelectionItem = {
id: string;
label: string;
description?: string;
};
/**
* Props for the SelectionDialog component
*/
export interface SelectionDialogProps {
/**
* The title of the dialog
*/
title: string;
/**
* The items to select from
*/
items: SelectionItem[];
/**
* Whether the dialog is open
*/
isOpen: boolean;
/**
* Callback when the dialog is closed
*/
onClose: () => void;
/**
* Callback when the confirm button is clicked with selected item IDs
*/
onConfirm: (selectedIds: string[]) => void;
/**
* The text for the confirm button
*/
confirmLabel?: string;
/**
* The maximum height of the selection list
*/
maxHeight?: string;
}
/**
* A reusable selection dialog component that uses the Dialog component
*/
export function SelectionDialog({
title,
items,
isOpen,
onClose,
onConfirm,
confirmLabel = 'Confirm',
maxHeight = '60vh',
}: SelectionDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
// Reset selected items when dialog opens
useEffect(() => {
if (isOpen) {
setSelectedItems([]);
setSelectAll(false);
}
}, [isOpen]);
const handleToggleItem = (id: string) => {
setSelectedItems((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]));
};
const handleSelectAll = () => {
if (selectedItems.length === items.length) {
setSelectedItems([]);
setSelectAll(false);
} else {
setSelectedItems(items.map((item) => item.id));
setSelectAll(true);
}
};
const handleConfirm = () => {
onConfirm(selectedItems);
onClose();
};
// Calculate the height for the virtualized list
const listHeight = Math.min(
items.length * 60,
parseInt(maxHeight.replace('vh', '')) * window.innerHeight * 0.01 - 40,
);
// Render each item in the virtualized list
const ItemRenderer = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const item = items[index];
return (
<div
key={item.id}
className={classNames(
'flex items-start space-x-3 p-2 rounded-md transition-colors',
selectedItems.includes(item.id)
? 'bg-bolt-elements-item-backgroundAccent'
: 'bg-bolt-elements-bg-depth-2 hover:bg-bolt-elements-item-backgroundActive',
)}
style={{
...style,
width: '100%',
boxSizing: 'border-box',
}}
>
<Checkbox
id={`item-${item.id}`}
checked={selectedItems.includes(item.id)}
onCheckedChange={() => handleToggleItem(item.id)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor={`item-${item.id}`}
className={classNames(
'text-sm font-medium cursor-pointer',
selectedItems.includes(item.id)
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-textPrimary',
)}
>
{item.label}
</Label>
{item.description && <p className="text-xs text-bolt-elements-textSecondary">{item.description}</p>}
</div>
</div>
);
};
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog showCloseButton={false}>
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-2 mb-4">
Select the items you want to include and click{' '}
<span className="text-bolt-elements-item-contentAccent font-medium">{confirmLabel}</span>.
</DialogDescription>
<div className="py-4">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-bolt-elements-textSecondary">
{selectedItems.length} of {items.length} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-bg-depth-2 dark:bg-transparent"
>
{selectAll ? 'Deselect All' : 'Select All'}
</Button>
</div>
<div
className="pr-2 border rounded-md border-bolt-elements-borderColor bg-bolt-elements-bg-depth-2"
style={{
maxHeight,
}}
>
{items.length > 0 ? (
<FixedSizeList
height={listHeight}
width="100%"
itemCount={items.length}
itemSize={60}
className="scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-bolt-elements-bg-depth-3"
>
{ItemRenderer}
</FixedSizeList>
) : (
<div className="text-center py-4 text-sm text-bolt-elements-textTertiary">No items to display</div>
)}
</div>
</div>
<div className="flex justify-between mt-6">
<Button
variant="outline"
onClick={onClose}
className="border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={selectedItems.length === 0}
className="bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:pointer-events-none"
>
{confirmLabel}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
}

View File

@ -0,0 +1,966 @@
import { useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import { ImportExportService } from '~/lib/services/importExportService';
import { useIndexedDB } from '~/lib/hooks/useIndexedDB';
import { generateId } from 'ai';
interface UseDataOperationsProps {
/**
* Callback to reload settings after import
*/
onReloadSettings?: () => void;
/**
* Callback to reload chats after import
*/
onReloadChats?: () => void;
/**
* Callback to reset settings to defaults
*/
onResetSettings?: () => void;
/**
* Callback to reset chats
*/
onResetChats?: () => void;
/**
* Custom database instance (optional)
*/
customDb?: IDBDatabase;
}
/**
* Hook for managing data operations in the DataTab
*/
export function useDataOperations({
onReloadSettings,
onReloadChats,
onResetSettings,
onResetChats,
customDb,
}: UseDataOperationsProps = {}) {
const { db: defaultDb } = useIndexedDB();
// Use the custom database if provided, otherwise use the default
const db = customDb || defaultDb;
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [progressPercent, setProgressPercent] = useState<number>(0);
const [lastOperation, setLastOperation] = useState<{ type: string; data: any } | null>(null);
/**
* Show progress toast with percentage
*/
const showProgress = useCallback((message: string, percent: number) => {
setProgressMessage(message);
setProgressPercent(percent);
toast.loading(`${message} (${percent}%)`, { toastId: 'operation-progress' });
}, []);
/**
* Export all settings to a JSON file
*/
const handleExportSettings = useCallback(async () => {
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing settings export...', { toastId: 'operation-progress' });
try {
// Step 1: Export settings
showProgress('Exporting settings', 25);
const settingsData = await ImportExportService.exportSettings();
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(settingsData, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-settings.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success('Settings exported successfully', { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-settings', data: settingsData });
} catch (error) {
console.error('Error exporting settings:', error);
toast.error(`Failed to export settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Export selected settings categories to a JSON file
* @param categoryIds Array of category IDs to export
*/
const handleExportSelectedSettings = useCallback(
async (categoryIds: string[]) => {
if (!categoryIds || categoryIds.length === 0) {
toast.error('No settings categories selected');
return;
}
setIsExporting(true);
setProgressPercent(0);
toast.loading(`Preparing export of ${categoryIds.length} settings categories...`, {
toastId: 'operation-progress',
});
try {
// Step 1: Export all settings
showProgress('Exporting settings', 20);
const allSettings = await ImportExportService.exportSettings();
// Step 2: Filter settings by category
showProgress('Filtering selected categories', 40);
const filteredSettings: Record<string, any> = {
exportDate: allSettings.exportDate,
};
// Add selected categories to filtered settings
categoryIds.forEach((category) => {
if (allSettings[category]) {
filteredSettings[category] = allSettings[category];
}
});
// Step 3: Create blob
showProgress('Creating file', 60);
const blob = new Blob([JSON.stringify(filteredSettings, null, 2)], {
type: 'application/json',
});
// Step 4: Download file
showProgress('Downloading file', 80);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-settings-selected.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 5: Complete
showProgress('Completing export', 100);
toast.success(`${categoryIds.length} settings categories exported successfully`, {
toastId: 'operation-progress',
});
// Save operation for potential undo
setLastOperation({ type: 'export-selected-settings', data: { categoryIds, settings: filteredSettings } });
} catch (error) {
console.error('Error exporting selected settings:', error);
toast.error(`Failed to export selected settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[showProgress],
);
/**
* Export all chats to a JSON file
*/
const handleExportAllChats = useCallback(async () => {
if (!db) {
toast.error('Database not available');
return;
}
console.log('Export: Using database', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing chats export...', { toastId: 'operation-progress' });
try {
// Step 1: Export chats
showProgress('Retrieving chats from database', 25);
console.log('Database details:', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
// Direct database query approach for more reliable access
const directChats = await new Promise<any[]>((resolve, reject) => {
try {
console.log(`Creating transaction on '${db.name}' database, objectStore 'chats'`);
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => {
console.log(`Found ${request.result ? request.result.length : 0} chats directly from database`);
resolve(request.result || []);
};
request.onerror = () => {
console.error('Error querying chats store:', request.error);
reject(request.error);
};
} catch (err) {
console.error('Error creating transaction:', err);
reject(err);
}
});
// Export data with direct chats
const exportData = {
chats: directChats,
exportDate: new Date().toISOString(),
};
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-chats.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success(`${exportData.chats.length} chats exported successfully`, { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-all-chats', data: exportData });
} catch (error) {
console.error('Error exporting chats:', error);
toast.error(`Failed to export chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, showProgress]);
/**
* Export selected chats to a JSON file
* @param chatIds Array of chat IDs to export
*/
const handleExportSelectedChats = useCallback(
async (chatIds: string[]) => {
if (!db) {
toast.error('Database not available');
return;
}
if (!chatIds || chatIds.length === 0) {
toast.error('No chats selected');
return;
}
setIsExporting(true);
setProgressPercent(0);
toast.loading(`Preparing export of ${chatIds.length} chats...`, { toastId: 'operation-progress' });
try {
// Step 1: Directly query each selected chat from database
showProgress('Retrieving selected chats from database', 20);
console.log('Database details for selected chats:', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
// Query each chat directly from the database
const selectedChats = await Promise.all(
chatIds.map(async (chatId) => {
return new Promise<any>((resolve, reject) => {
try {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.get(chatId);
request.onsuccess = () => {
if (request.result) {
console.log(`Found chat with ID ${chatId}:`, {
id: request.result.id,
messageCount: request.result.messages?.length || 0,
});
} else {
console.log(`Chat with ID ${chatId} not found`);
}
resolve(request.result || null);
};
request.onerror = () => {
console.error(`Error retrieving chat ${chatId}:`, request.error);
reject(request.error);
};
} catch (err) {
console.error(`Error in transaction for chat ${chatId}:`, err);
reject(err);
}
});
}),
);
// Filter out any null results (chats that weren't found)
const filteredChats = selectedChats.filter((chat) => chat !== null);
console.log(`Found ${filteredChats.length} selected chats out of ${chatIds.length} requested`);
// Step 2: Prepare export data
showProgress('Preparing export data', 40);
const exportData = {
chats: filteredChats,
exportDate: new Date().toISOString(),
};
// Step 3: Create blob
showProgress('Creating file', 60);
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
// Step 4: Download file
showProgress('Downloading file', 80);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-chats-selected.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 5: Complete
showProgress('Completing export', 100);
toast.success(`${filteredChats.length} chats exported successfully`, { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-selected-chats', data: { chatIds, chats: filteredChats } });
} catch (error) {
console.error('Error exporting selected chats:', error);
toast.error(`Failed to export selected chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[db, showProgress],
);
/**
* Import settings from a JSON file
* @param file The file to import
*/
const handleImportSettings = useCallback(
async (file: File) => {
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing settings from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON
showProgress('Parsing settings data', 40);
const importedData = JSON.parse(fileContent);
// Step 3: Validate data
showProgress('Validating settings data', 60);
// Save current settings for potential undo
const currentSettings = await ImportExportService.exportSettings();
setLastOperation({ type: 'import-settings', data: { previous: currentSettings } });
// Step 4: Import settings
showProgress('Applying settings', 80);
await ImportExportService.importSettings(importedData);
// Step 5: Complete
showProgress('Completing import', 100);
toast.success('Settings imported successfully', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
} catch (error) {
console.error('Error importing settings:', error);
toast.error(`Failed to import settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[onReloadSettings, showProgress],
);
/**
* Import chats from a JSON file
* @param file The file to import
*/
const handleImportChats = useCallback(
async (file: File) => {
if (!db) {
toast.error('Database not available');
return;
}
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing chats from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON and validate structure
showProgress('Parsing chat data', 40);
const importedData = JSON.parse(fileContent);
if (!importedData.chats || !Array.isArray(importedData.chats)) {
throw new Error('Invalid chat data format: missing or invalid chats array');
}
// Step 3: Validate each chat object
showProgress('Validating chat data', 60);
const validatedChats = importedData.chats.map((chat: any) => {
if (!chat.id || !Array.isArray(chat.messages)) {
throw new Error('Invalid chat format: missing required fields');
}
// Ensure each message has required fields
const validatedMessages = chat.messages.map((msg: any) => {
if (!msg.role || !msg.content) {
throw new Error('Invalid message format: missing required fields');
}
return {
id: msg.id || generateId(),
role: msg.role,
content: msg.content,
name: msg.name,
function_call: msg.function_call,
timestamp: msg.timestamp || Date.now(),
};
});
return {
id: chat.id,
description: chat.description || '',
messages: validatedMessages,
timestamp: chat.timestamp || new Date().toISOString(),
urlId: chat.urlId || null,
metadata: chat.metadata || null,
};
});
// Step 4: Save current chats for potential undo
showProgress('Preparing database transaction', 70);
const currentChats = await ImportExportService.exportAllChats(db);
setLastOperation({ type: 'import-chats', data: { previous: currentChats } });
// Step 5: Import chats
showProgress(`Importing ${validatedChats.length} chats`, 80);
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
let processed = 0;
for (const chat of validatedChats) {
store.put(chat);
processed++;
if (processed % 5 === 0 || processed === validatedChats.length) {
showProgress(
`Imported ${processed} of ${validatedChats.length} chats`,
80 + (processed / validatedChats.length) * 20,
);
}
}
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
// Step 6: Complete
showProgress('Completing import', 100);
toast.success(`${validatedChats.length} chats imported successfully`, { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
} catch (error) {
console.error('Error importing chats:', error);
toast.error(`Failed to import chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[db, onReloadChats, showProgress],
);
/**
* Import API keys from a JSON file
* @param file The file to import
*/
const handleImportAPIKeys = useCallback(
async (file: File) => {
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing API keys from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON
showProgress('Parsing API keys data', 40);
const importedData = JSON.parse(fileContent);
// Step 3: Validate data
showProgress('Validating API keys data', 60);
// Get current API keys from cookies for potential undo
const apiKeysStr = document.cookie.split(';').find((row) => row.trim().startsWith('apiKeys='));
const currentApiKeys = apiKeysStr ? JSON.parse(decodeURIComponent(apiKeysStr.split('=')[1])) : {};
setLastOperation({ type: 'import-api-keys', data: { previous: currentApiKeys } });
// Step 4: Import API keys
showProgress('Applying API keys', 80);
const newKeys = ImportExportService.importAPIKeys(importedData);
const apiKeysJson = JSON.stringify(newKeys);
document.cookie = `apiKeys=${apiKeysJson}; path=/; max-age=31536000`;
// Step 5: Complete
showProgress('Completing import', 100);
// Count how many keys were imported
const keyCount = Object.keys(newKeys).length;
const newKeyCount = Object.keys(newKeys).filter(
(key) => !currentApiKeys[key] || currentApiKeys[key] !== newKeys[key],
).length;
toast.success(
`${keyCount} API keys imported successfully (${newKeyCount} new/updated)\n` +
'Note: Keys are stored in browser cookies. For server-side usage, add them to your .env.local file.',
{ toastId: 'operation-progress', autoClose: 5000 },
);
if (onReloadSettings) {
onReloadSettings();
}
} catch (error) {
console.error('Error importing API keys:', error);
toast.error(`Failed to import API keys: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[onReloadSettings, showProgress],
);
/**
* Reset all settings to default values
*/
const handleResetSettings = useCallback(async () => {
setIsResetting(true);
setProgressPercent(0);
toast.loading('Resetting settings...', { toastId: 'operation-progress' });
try {
if (db) {
// Step 1: Save current settings for potential undo
showProgress('Backing up current settings', 25);
const currentSettings = await ImportExportService.exportSettings();
setLastOperation({ type: 'reset-settings', data: { previous: currentSettings } });
// Step 2: Reset settings
showProgress('Resetting settings to defaults', 50);
await ImportExportService.resetAllSettings(db);
// Step 3: Complete
showProgress('Completing reset', 100);
toast.success('Settings reset successfully', { toastId: 'operation-progress' });
if (onResetSettings) {
onResetSettings();
}
} else {
toast.error('Database not available', { toastId: 'operation-progress' });
}
} catch (error) {
console.error('Error resetting settings:', error);
toast.error(`Failed to reset settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsResetting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, onResetSettings, showProgress]);
/**
* Reset all chats
*/
const handleResetChats = useCallback(async () => {
if (!db) {
toast.error('Database not available');
return;
}
setIsResetting(true);
setProgressPercent(0);
toast.loading('Deleting all chats...', { toastId: 'operation-progress' });
try {
// Step 1: Save current chats for potential undo
showProgress('Backing up current chats', 25);
const currentChats = await ImportExportService.exportAllChats(db);
setLastOperation({ type: 'reset-chats', data: { previous: currentChats } });
// Step 2: Delete chats
showProgress('Deleting chats from database', 50);
await ImportExportService.deleteAllChats(db);
// Step 3: Complete
showProgress('Completing deletion', 100);
toast.success('All chats deleted successfully', { toastId: 'operation-progress' });
if (onResetChats) {
onResetChats();
}
} catch (error) {
console.error('Error resetting chats:', error);
toast.error(`Failed to delete chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsResetting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, onResetChats, showProgress]);
/**
* Download API keys template
*/
const handleDownloadTemplate = useCallback(async () => {
setIsDownloadingTemplate(true);
setProgressPercent(0);
toast.loading('Preparing API keys template...', { toastId: 'operation-progress' });
try {
// Step 1: Create template
showProgress('Creating template', 50);
const templateData = ImportExportService.createAPIKeysTemplate();
// Step 2: Download file
showProgress('Downloading template', 75);
const blob = new Blob([JSON.stringify(templateData, 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);
// Step 3: Complete
showProgress('Completing download', 100);
toast.success('API keys template downloaded successfully', { toastId: 'operation-progress' });
} catch (error) {
console.error('Error downloading template:', error);
toast.error(`Failed to download template: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsDownloadingTemplate(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Export API keys to a JSON file
*/
const handleExportAPIKeys = useCallback(async () => {
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing API keys export...', { toastId: 'operation-progress' });
try {
// Step 1: Get API keys from all sources
showProgress('Retrieving API keys', 25);
// Create a fetch request to get API keys from server
const response = await fetch('/api/export-api-keys');
if (!response.ok) {
throw new Error('Failed to retrieve API keys from server');
}
const apiKeys = await response.json();
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(apiKeys, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-api-keys.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success('API keys exported successfully', { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-api-keys', data: apiKeys });
} catch (error) {
console.error('Error exporting API keys:', error);
toast.error(`Failed to export API keys: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Undo the last operation if possible
*/
const handleUndo = useCallback(async () => {
if (!lastOperation || !db) {
toast.error('Nothing to undo');
return;
}
toast.loading('Attempting to undo last operation...', { toastId: 'operation-progress' });
try {
switch (lastOperation.type) {
case 'import-settings': {
// Restore previous settings
await ImportExportService.importSettings(lastOperation.data.previous);
toast.success('Settings import undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
case 'import-chats': {
// Delete imported chats and restore previous state
await ImportExportService.deleteAllChats(db);
// Reimport previous chats
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
for (const chat of lastOperation.data.previous.chats) {
store.put(chat);
}
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
toast.success('Chats import undone', { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
break;
}
case 'reset-settings': {
// Restore previous settings
await ImportExportService.importSettings(lastOperation.data.previous);
toast.success('Settings reset undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
case 'reset-chats': {
// Restore previous chats
const chatTransaction = db.transaction(['chats'], 'readwrite');
const chatStore = chatTransaction.objectStore('chats');
for (const chat of lastOperation.data.previous.chats) {
chatStore.put(chat);
}
await new Promise((resolve, reject) => {
chatTransaction.oncomplete = resolve;
chatTransaction.onerror = reject;
});
toast.success('Chats deletion undone', { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
break;
}
case 'import-api-keys': {
// Restore previous API keys
const previousAPIKeys = lastOperation.data.previous;
const newKeys = ImportExportService.importAPIKeys(previousAPIKeys);
const apiKeysJson = JSON.stringify(newKeys);
document.cookie = `apiKeys=${apiKeysJson}; path=/; max-age=31536000`;
toast.success('API keys import undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
default:
toast.error('Cannot undo this operation', { toastId: 'operation-progress' });
}
// Clear the last operation after undoing
setLastOperation(null);
} catch (error) {
console.error('Error undoing operation:', error);
toast.error(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
}
}, [lastOperation, db, onReloadSettings, onReloadChats]);
return {
isExporting,
isImporting,
isResetting,
isDownloadingTemplate,
progressMessage,
progressPercent,
lastOperation,
handleExportSettings,
handleExportSelectedSettings,
handleExportAllChats,
handleExportSelectedChats,
handleImportSettings,
handleImportChats,
handleImportAPIKeys,
handleResetSettings,
handleResetChats,
handleDownloadTemplate,
handleExportAPIKeys,
handleUndo,
};
}

View File

@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
/**
* Hook to initialize and provide access to the IndexedDB database
*/
export function useIndexedDB() {
const [db, setDb] = useState<IDBDatabase | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const initDB = async () => {
try {
setIsLoading(true);
const request = indexedDB.open('boltDB', 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores if they don't exist
if (!db.objectStoreNames.contains('chats')) {
const chatStore = db.createObjectStore('chats', { keyPath: 'id' });
chatStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
setDb(database);
setIsLoading(false);
};
request.onerror = (event) => {
setError(new Error(`Database error: ${(event.target as IDBOpenDBRequest).error?.message}`));
setIsLoading(false);
};
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error initializing database'));
setIsLoading(false);
}
};
initDB();
return () => {
if (db) {
db.close();
}
};
}, []);
return { db, isLoading, error };
}

View File

@ -0,0 +1,140 @@
/**
* Functions for managing chat data in IndexedDB
*/
import type { Message } from 'ai';
import type { IChatMetadata } from './db'; // Import IChatMetadata
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface Chat {
id: string;
description?: string;
messages: Message[];
timestamp: string;
urlId?: string;
metadata?: IChatMetadata;
}
/**
* Get all chats from the database
* @param db The IndexedDB database instance
* @returns A promise that resolves to an array of chats
*/
export async function getAllChats(db: IDBDatabase): Promise<Chat[]> {
console.log(`getAllChats: Using database '${db.name}', version ${db.version}`);
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => {
const result = request.result || [];
console.log(`getAllChats: Found ${result.length} chats in database '${db.name}'`);
resolve(result);
};
request.onerror = () => {
console.error(`getAllChats: Error querying database '${db.name}':`, request.error);
reject(request.error);
};
} catch (err) {
console.error(`getAllChats: Error creating transaction on database '${db.name}':`, err);
reject(err);
}
});
}
/**
* Get a chat by ID
* @param db The IndexedDB database instance
* @param id The ID of the chat to get
* @returns A promise that resolves to the chat or null if not found
*/
export async function getChatById(db: IDBDatabase, id: string): Promise<Chat | null> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Save a chat to the database
* @param db The IndexedDB database instance
* @param chat The chat to save
* @returns A promise that resolves when the chat is saved
*/
export async function saveChat(db: IDBDatabase, chat: Chat): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.put(chat);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Delete a chat by ID
* @param db The IndexedDB database instance
* @param id The ID of the chat to delete
* @returns A promise that resolves when the chat is deleted
*/
export async function deleteChat(db: IDBDatabase, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Delete all chats
* @param db The IndexedDB database instance
* @returns A promise that resolves when all chats are deleted
*/
export async function deleteAllChats(db: IDBDatabase): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}

View File

@ -0,0 +1,695 @@
import Cookies from 'js-cookie';
import { type Message } from 'ai';
import { getAllChats, deleteChat } from '~/lib/persistence/chats';
interface ExtendedMessage extends Message {
name?: string;
function_call?: any;
timestamp?: number;
}
/**
* Service for handling import and export operations of application data
*/
export class ImportExportService {
/**
* Export all chats to a JSON file
* @param db The IndexedDB database instance
* @returns A promise that resolves to the export data
*/
static async exportAllChats(db: IDBDatabase): Promise<{ chats: any[]; exportDate: string }> {
if (!db) {
throw new Error('Database not initialized');
}
try {
// Get all chats from the database using the getAllChats helper
const chats = await getAllChats(db);
// Validate and sanitize each chat before export
const sanitizedChats = chats.map((chat) => ({
id: chat.id,
description: chat.description || '',
messages: chat.messages.map((msg: ExtendedMessage) => ({
id: msg.id,
role: msg.role,
content: msg.content,
name: msg.name,
function_call: msg.function_call,
timestamp: msg.timestamp,
})),
timestamp: chat.timestamp,
urlId: chat.urlId || null,
metadata: chat.metadata || null,
}));
console.log(`Successfully prepared ${sanitizedChats.length} chats for export`);
return {
chats: sanitizedChats,
exportDate: new Date().toISOString(),
};
} catch (error) {
console.error('Error exporting chats:', error);
throw new Error(`Failed to export chats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Export application settings to a JSON file
* @returns A promise that resolves to the settings data
*/
static async exportSettings(): Promise<any> {
try {
// Get all cookies
const allCookies = Cookies.get();
// Create a comprehensive settings object
return {
// Core settings
core: {
// User profile and main settings
bolt_user_profile: this._safeGetItem('bolt_user_profile'),
bolt_settings: this._safeGetItem('bolt_settings'),
bolt_profile: this._safeGetItem('bolt_profile'),
theme: this._safeGetItem('theme'),
},
// Provider settings (both local and cloud)
providers: {
// Provider configurations from localStorage
provider_settings: this._safeGetItem('provider_settings'),
// API keys from cookies
apiKeys: allCookies.apiKeys,
// Selected provider and model
selectedModel: allCookies.selectedModel,
selectedProvider: allCookies.selectedProvider,
// Provider-specific settings
providers: allCookies.providers,
},
// Feature settings
features: {
// Feature flags
viewed_features: this._safeGetItem('bolt_viewed_features'),
developer_mode: this._safeGetItem('bolt_developer_mode'),
// Context optimization
contextOptimizationEnabled: this._safeGetItem('contextOptimizationEnabled'),
// Auto-select template
autoSelectTemplate: this._safeGetItem('autoSelectTemplate'),
// Latest branch
isLatestBranch: this._safeGetItem('isLatestBranch'),
// Event logs
isEventLogsEnabled: this._safeGetItem('isEventLogsEnabled'),
// Energy saver settings
energySaverMode: this._safeGetItem('energySaverMode'),
autoEnergySaver: this._safeGetItem('autoEnergySaver'),
},
// UI configuration
ui: {
// Tab configuration
bolt_tab_configuration: this._safeGetItem('bolt_tab_configuration'),
tabConfiguration: allCookies.tabConfiguration,
// Prompt settings
promptId: this._safeGetItem('promptId'),
cachedPrompt: allCookies.cachedPrompt,
},
// Connections
connections: {
// Netlify connection
netlify_connection: this._safeGetItem('netlify_connection'),
// GitHub connections
...this._getGitHubConnections(allCookies),
},
// Debug and logs
debug: {
// Debug settings
isDebugEnabled: allCookies.isDebugEnabled,
acknowledged_debug_issues: this._safeGetItem('bolt_acknowledged_debug_issues'),
acknowledged_connection_issue: this._safeGetItem('bolt_acknowledged_connection_issue'),
// Error logs
error_logs: this._safeGetItem('error_logs'),
bolt_read_logs: this._safeGetItem('bolt_read_logs'),
// Event logs
eventLogs: allCookies.eventLogs,
},
// Update settings
updates: {
update_settings: this._safeGetItem('update_settings'),
last_acknowledged_update: this._safeGetItem('bolt_last_acknowledged_version'),
},
// Chat snapshots (for chat history)
chatSnapshots: this._getChatSnapshots(),
// Raw data (for debugging and complete backup)
_raw: {
localStorage: this._getAllLocalStorage(),
cookies: allCookies,
},
// Export metadata
_meta: {
exportDate: new Date().toISOString(),
version: '2.0',
appVersion: process.env.NEXT_PUBLIC_VERSION || 'unknown',
},
};
} catch (error) {
console.error('Error exporting settings:', error);
throw error;
}
}
/**
* Import settings from a JSON file
* @param importedData The imported data
*/
static async importSettings(importedData: any): Promise<void> {
// Check if this is the new comprehensive format (v2.0)
const isNewFormat = importedData._meta?.version === '2.0';
if (isNewFormat) {
// Import using the new comprehensive format
await this._importComprehensiveFormat(importedData);
} else {
// Try to handle older formats
await this._importLegacyFormat(importedData);
}
}
/**
* Import API keys from a JSON file
* @param keys The API keys to import
*/
static importAPIKeys(keys: Record<string, any>): Record<string, string> {
// Get existing keys from cookies
const existingKeys = (() => {
const storedApiKeys = Cookies.get('apiKeys');
return storedApiKeys ? JSON.parse(storedApiKeys) : {};
})();
// Validate and save each key
const newKeys = { ...existingKeys };
Object.entries(keys).forEach(([key, value]) => {
// Skip comment fields
if (key.startsWith('_')) {
return;
}
// Skip base URL fields (they should be set in .env.local)
if (key.includes('_API_BASE_URL')) {
return;
}
if (typeof value !== 'string') {
throw new Error(`Invalid value for key: ${key}`);
}
// Handle both old and new template formats
let normalizedKey = key;
// Check if this is the old format (e.g., "Anthropic_API_KEY")
if (key.includes('_API_KEY')) {
// Extract the provider name from the old format
normalizedKey = key.replace('_API_KEY', '');
}
/*
* Only add non-empty keys
* Use the normalized key in the correct format
* (e.g., "OpenAI", "Google", "Anthropic")
*/
if (value) {
newKeys[normalizedKey] = value;
}
});
return newKeys;
}
/**
* Create an API keys template
* @returns The API keys template
*/
static createAPIKeysTemplate(): Record<string, any> {
/*
* Create a template with provider names as keys
* This matches how the application stores API keys in cookies
*/
const template = {
Anthropic: '',
OpenAI: '',
Google: '',
Groq: '',
HuggingFace: '',
OpenRouter: '',
Deepseek: '',
Mistral: '',
OpenAILike: '',
Together: '',
xAI: '',
Perplexity: '',
Cohere: '',
AzureOpenAI: '',
};
// Add a comment to explain the format
return {
_comment:
"Fill in your API keys for each provider. Keys will be stored with the provider name (e.g., 'OpenAI'). The application also supports the older format with keys like 'OpenAI_API_KEY' for backward compatibility.",
...template,
};
}
/**
* Reset all settings to default values
* @param db The IndexedDB database instance
*/
static async resetAllSettings(db: IDBDatabase): Promise<void> {
// 1. Clear all localStorage items related to application settings
const localStorageKeysToPreserve: string[] = ['debug_mode']; // Keys to preserve if needed
// Get all localStorage keys
const allLocalStorageKeys = Object.keys(localStorage);
// Clear all localStorage items except those to preserve
allLocalStorageKeys.forEach((key) => {
if (!localStorageKeysToPreserve.includes(key)) {
try {
localStorage.removeItem(key);
} catch (err) {
console.error(`Error removing localStorage item ${key}:`, err);
}
}
});
// 2. Clear all cookies related to application settings
const cookiesToPreserve: string[] = []; // Cookies to preserve if needed
// Get all cookies
const allCookies = Cookies.get();
const cookieKeys = Object.keys(allCookies);
// Clear all cookies except those to preserve
cookieKeys.forEach((key) => {
if (!cookiesToPreserve.includes(key)) {
try {
Cookies.remove(key);
} catch (err) {
console.error(`Error removing cookie ${key}:`, err);
}
}
});
// 3. Clear all data from IndexedDB
if (!db) {
console.warn('Database not initialized, skipping IndexedDB reset');
} else {
// Get all chats and delete them
const chats = await getAllChats(db);
const deletePromises = chats.map((chat) => deleteChat(db, chat.id));
await Promise.all(deletePromises);
}
// 4. Clear any chat snapshots
const snapshotKeys = Object.keys(localStorage).filter((key) => key.startsWith('snapshot:'));
snapshotKeys.forEach((key) => {
try {
localStorage.removeItem(key);
} catch (err) {
console.error(`Error removing snapshot ${key}:`, err);
}
});
}
/**
* Delete all chats from the database
* @param db The IndexedDB database instance
*/
static async deleteAllChats(db: IDBDatabase): Promise<void> {
// Clear chat history from localStorage
localStorage.removeItem('bolt_chat_history');
// Clear chats from IndexedDB
if (!db) {
throw new Error('Database not initialized');
}
// Get all chats and delete them one by one
const chats = await getAllChats(db);
const deletePromises = chats.map((chat) => deleteChat(db, chat.id));
await Promise.all(deletePromises);
}
// Private helper methods
/**
* Import settings from a comprehensive format
* @param data The imported data
*/
private static async _importComprehensiveFormat(data: any): Promise<void> {
// Import core settings
if (data.core) {
Object.entries(data.core).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing core setting ${key}:`, err);
}
}
});
}
// Import provider settings
if (data.providers) {
// Import provider_settings to localStorage
if (data.providers.provider_settings) {
try {
this._safeSetItem('provider_settings', data.providers.provider_settings);
} catch (err) {
console.error('Error importing provider settings:', err);
}
}
// Import API keys and other provider cookies
const providerCookies = ['apiKeys', 'selectedModel', 'selectedProvider', 'providers'];
providerCookies.forEach((key) => {
if (data.providers[key]) {
try {
this._safeSetCookie(key, data.providers[key]);
} catch (err) {
console.error(`Error importing provider cookie ${key}:`, err);
}
}
});
}
// Import feature settings
if (data.features) {
Object.entries(data.features).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing feature setting ${key}:`, err);
}
}
});
}
// Import UI configuration
if (data.ui) {
// Import localStorage UI settings
if (data.ui.bolt_tab_configuration) {
try {
this._safeSetItem('bolt_tab_configuration', data.ui.bolt_tab_configuration);
} catch (err) {
console.error('Error importing tab configuration:', err);
}
}
if (data.ui.promptId) {
try {
this._safeSetItem('promptId', data.ui.promptId);
} catch (err) {
console.error('Error importing prompt ID:', err);
}
}
// Import UI cookies
const uiCookies = ['tabConfiguration', 'cachedPrompt'];
uiCookies.forEach((key) => {
if (data.ui[key]) {
try {
this._safeSetCookie(key, data.ui[key]);
} catch (err) {
console.error(`Error importing UI cookie ${key}:`, err);
}
}
});
}
// Import connections
if (data.connections) {
// Import Netlify connection
if (data.connections.netlify_connection) {
try {
this._safeSetItem('netlify_connection', data.connections.netlify_connection);
} catch (err) {
console.error('Error importing Netlify connection:', err);
}
}
// Import GitHub connections
Object.entries(data.connections).forEach(([key, value]) => {
if (key.startsWith('github_') && value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing GitHub connection ${key}:`, err);
}
}
});
}
// Import debug settings
if (data.debug) {
// Import debug localStorage settings
const debugLocalStorageKeys = [
'bolt_acknowledged_debug_issues',
'bolt_acknowledged_connection_issue',
'error_logs',
'bolt_read_logs',
];
debugLocalStorageKeys.forEach((key) => {
if (data.debug[key] !== null && data.debug[key] !== undefined) {
try {
this._safeSetItem(key, data.debug[key]);
} catch (err) {
console.error(`Error importing debug setting ${key}:`, err);
}
}
});
// Import debug cookies
const debugCookies = ['isDebugEnabled', 'eventLogs'];
debugCookies.forEach((key) => {
if (data.debug[key]) {
try {
this._safeSetCookie(key, data.debug[key]);
} catch (err) {
console.error(`Error importing debug cookie ${key}:`, err);
}
}
});
}
// Import update settings
if (data.updates) {
if (data.updates.update_settings) {
try {
this._safeSetItem('update_settings', data.updates.update_settings);
} catch (err) {
console.error('Error importing update settings:', err);
}
}
if (data.updates.last_acknowledged_update) {
try {
this._safeSetItem('bolt_last_acknowledged_version', data.updates.last_acknowledged_update);
} catch (err) {
console.error('Error importing last acknowledged update:', err);
}
}
}
// Import chat snapshots
if (data.chatSnapshots) {
Object.entries(data.chatSnapshots).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing chat snapshot ${key}:`, err);
}
}
});
}
}
/**
* Import settings from a legacy format
* @param data The imported data
*/
private static async _importLegacyFormat(data: any): Promise<void> {
/**
* Handle legacy format (v1.0 or earlier)
* This is a simplified version that tries to import whatever is available
*/
// Try to import settings directly
Object.entries(data).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
// Skip metadata fields
if (key === 'exportDate' || key === 'version' || key === 'appVersion') {
return;
}
try {
// Try to determine if this should be a cookie or localStorage item
const isCookie = [
'apiKeys',
'selectedModel',
'selectedProvider',
'providers',
'tabConfiguration',
'cachedPrompt',
'isDebugEnabled',
'eventLogs',
].includes(key);
if (isCookie) {
this._safeSetCookie(key, value);
} else {
this._safeSetItem(key, value);
}
} catch (err) {
console.error(`Error importing legacy setting ${key}:`, err);
}
}
});
}
/**
* Safely get an item from localStorage
* @param key The key to get
* @returns The value or null if not found
*/
private static _safeGetItem(key: string): any {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (err) {
console.error(`Error getting localStorage item ${key}:`, err);
return null;
}
}
/**
* Get all localStorage items
* @returns All localStorage items
*/
private static _getAllLocalStorage(): Record<string, any> {
const result: Record<string, any> = {};
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch {
result[key] = null;
}
}
}
} catch (err) {
console.error('Error getting all localStorage items:', err);
}
return result;
}
/**
* Get GitHub connections from cookies
* @param _cookies The cookies object
* @returns GitHub connections
*/
private static _getGitHubConnections(_cookies: Record<string, string>): Record<string, any> {
const result: Record<string, any> = {};
// Get GitHub connections from localStorage
const localStorageKeys = Object.keys(localStorage).filter((key) => key.startsWith('github_'));
localStorageKeys.forEach((key) => {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch (err) {
console.error(`Error getting GitHub connection ${key}:`, err);
result[key] = null;
}
});
return result;
}
/**
* Get chat snapshots from localStorage
* @returns Chat snapshots
*/
private static _getChatSnapshots(): Record<string, any> {
const result: Record<string, any> = {};
// Get chat snapshots from localStorage
const snapshotKeys = Object.keys(localStorage).filter((key) => key.startsWith('snapshot:'));
snapshotKeys.forEach((key) => {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch (err) {
console.error(`Error getting chat snapshot ${key}:`, err);
result[key] = null;
}
});
return result;
}
/**
* Safely set an item in localStorage
* @param key The key to set
* @param value The value to set
*/
private static _safeSetItem(key: string, value: any): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error(`Error setting localStorage item ${key}:`, err);
}
}
/**
* Safely set a cookie
* @param key The key to set
* @param value The value to set
*/
private static _safeSetCookie(key: string, value: any): void {
try {
Cookies.set(key, typeof value === 'string' ? value : JSON.stringify(value), { expires: 365 });
} catch (err) {
console.error(`Error setting cookie ${key}:`, err);
}
}
}

View File

@ -0,0 +1,44 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
import { LLMManager } from '~/lib/modules/llm/manager';
import { getApiKeysFromCookie } from '~/lib/api/cookies';
export const loader: LoaderFunction = async ({ context, request }) => {
// Get API keys from cookie
const cookieHeader = request.headers.get('Cookie');
const apiKeysFromCookie = getApiKeysFromCookie(cookieHeader);
// Initialize the LLM manager to access environment variables
const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any);
// Get all provider instances to find their API token keys
const providers = llmManager.getAllProviders();
// Create a comprehensive API keys object
const apiKeys: Record<string, string> = { ...apiKeysFromCookie };
// For each provider, check all possible sources for API keys
for (const provider of providers) {
if (!provider.config.apiTokenKey) {
continue;
}
const envVarName = provider.config.apiTokenKey;
// Skip if we already have this provider's key from cookies
if (apiKeys[provider.name]) {
continue;
}
// Check environment variables in order of precedence
const envValue =
(context?.cloudflare?.env as Record<string, any>)?.[envVarName] ||
process.env[envVarName] ||
llmManager.env[envVarName];
if (envValue) {
apiKeys[provider.name] = envValue;
}
}
return Response.json(apiKeys);
};

View File

@ -69,4 +69,4 @@
.diff-removed {
@apply bg-red-500/20 border-l-4 border-red-500;
}
}

View File

@ -79,6 +79,7 @@
"@octokit/types": "^13.6.2",
"@openrouter/ai-sdk-provider": "^0.0.5",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.5",
@ -124,6 +125,7 @@
"js-cookie": "^3.0.5",
"jspdf": "^2.5.2",
"jszip": "^3.10.1",
"lucide-react": "^0.485.0",
"mime": "^4.0.4",
"nanostores": "^0.10.3",
"ollama-ai-provider": "^0.15.2",
@ -139,6 +141,7 @@
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
"react-toastify": "^10.0.6",
"react-window": "^1.8.11",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
@ -170,6 +173,7 @@
"@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",

File diff suppressed because it is too large Load Diff