mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-09 06:29:43 +00:00
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:
parent
47444970e8
commit
b86fd63700
7
.gitignore
vendored
7
.gitignore
vendored
@ -44,11 +44,4 @@ changelogUI.md
|
||||
docs/instructions/Roadmap.md
|
||||
.cursorrules
|
||||
*.md
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
.qodo
|
||||
exponent.txt
|
||||
<<<<<<< Updated upstream
|
||||
>>>>>>> Stashed changes
|
||||
=======
|
||||
>>>>>>> Stashed changes
|
||||
|
@ -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
384
app/components/@settings/tabs/data/DataVisualization.tsx
Normal file
384
app/components/@settings/tabs/data/DataVisualization.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
||||
|
25
app/components/ui/Checkbox.tsx
Normal file
25
app/components/ui/Checkbox.tsx
Normal 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 };
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
966
app/lib/hooks/useDataOperations.ts
Normal file
966
app/lib/hooks/useDataOperations.ts
Normal 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,
|
||||
};
|
||||
}
|
58
app/lib/hooks/useIndexedDB.ts
Normal file
58
app/lib/hooks/useIndexedDB.ts
Normal 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 };
|
||||
}
|
140
app/lib/persistence/chats.ts
Normal file
140
app/lib/persistence/chats.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
695
app/lib/services/importExportService.ts
Normal file
695
app/lib/services/importExportService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
44
app/routes/api.export-api-keys.ts
Normal file
44
app/routes/api.export-api-keys.ts
Normal 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);
|
||||
};
|
@ -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",
|
||||
|
6665
pnpm-lock.yaml
6665
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user