Final UI V3

# UI V3 Changelog

Major updates and improvements in this release:

## Core Changes
- Complete NEW REWRITTEN UI system overhaul (V3) with semantic design tokens
- New settings management system with drag-and-drop capabilities
- Enhanced provider system supporting multiple AI services
- Improved theme system with better dark mode support
- New component library with consistent design patterns

## Technical Updates
- Reorganized project architecture for better maintainability
- Performance optimizations and bundle size improvements
- Enhanced security features and access controls
- Improved developer experience with better tooling
- Comprehensive testing infrastructure

## New Features
- Background rays effect for improved visual feedback
- Advanced tab management system
- Automatic and manual update support
- Enhanced error handling and visualization
- Improved accessibility across all components

For detailed information about all changes and improvements, please see the full changelog.
This commit is contained in:
Stijnus 2025-02-02 01:42:30 +01:00
parent 999d87b1e8
commit fc3dd8c84c
76 changed files with 4540 additions and 4936 deletions

1
.gitignore vendored
View File

@ -44,3 +44,4 @@ changelogUI.md
docs/instructions/Roadmap.md
.cursorrules
.cursorrules
*.md

View File

@ -0,0 +1,181 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, Profile } from './types';
interface AvatarDropdownProps {
onSelectTab: (tab: TabType) => void;
}
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
const profile = useStore(profileStore) as Profile;
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<motion.button
className="group flex items-center justify-center"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div
className={classNames(
'w-10 h-10',
'rounded-full overflow-hidden',
'bg-gray-100/50 dark:bg-gray-800/50',
'flex items-center justify-center',
'ring-1 ring-gray-200/50 dark:ring-gray-700/50',
'group-hover:ring-purple-500/50 dark:group-hover:ring-purple-500/50',
'group-hover:bg-purple-500/10 dark:group-hover:bg-purple-500/10',
'transition-all duration-200',
'relative',
)}
>
{profile?.avatar ? (
<div className="w-full h-full">
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className={classNames(
'w-full h-full',
'object-cover',
'transform-gpu',
'image-rendering-crisp',
'group-hover:brightness-110',
'group-hover:scale-105',
'transition-all duration-200',
)}
loading="eager"
decoding="sync"
/>
<div
className={classNames(
'absolute inset-0',
'ring-1 ring-inset ring-black/5 dark:ring-white/5',
'group-hover:ring-purple-500/20 dark:group-hover:ring-purple-500/20',
'group-hover:bg-purple-500/5 dark:group-hover:bg-purple-500/5',
'transition-colors duration-200',
)}
/>
</div>
) : (
<div className="i-ph:robot-fill w-6 h-6 text-gray-400 dark:text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
)}
</div>
</motion.button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<div
className={classNames(
'px-4 py-3 flex items-center gap-3',
'border-b border-gray-200/50 dark:border-gray-800/50',
)}
>
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-100/50 dark:bg-gray-800/50 flex-shrink-0">
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'Profile'}
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
loading="eager"
decoding="sync"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="i-ph:robot-fill w-6 h-6 text-gray-400 dark:text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
{profile?.username || 'Guest User'}
</div>
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
</div>
</div>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('profile')}
>
<div className="i-ph:robot-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Edit Profile
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('settings')}
>
<div className="i-ph:gear-six-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Settings
</DropdownMenu.Item>
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('task-manager')}
>
<div className="i-ph:activity-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Task Manager
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'flex items-center gap-2 px-4 py-2.5',
'text-sm text-gray-700 dark:text-gray-200',
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
'hover:text-purple-500 dark:hover:text-purple-400',
'cursor-pointer transition-all duration-200',
'outline-none',
'group',
)}
onClick={() => onSelectTab('service-status')}
>
<div className="i-ph:heartbeat-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
Service Status
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@ -0,0 +1,459 @@
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { Switch } from '@radix-ui/react-switch';
import * as RadixDialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
import { TabTile } from '~/components/@settings/shared/components/TabTile';
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
import { useFeatures } from '~/lib/hooks/useFeatures';
import { useNotifications } from '~/lib/hooks/useNotifications';
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
import { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings';
import { profileStore } from '~/lib/stores/profile';
import type { TabType, TabVisibilityConfig, DevTabConfig, Profile } from './types';
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
import { resetTabConfiguration } from '~/lib/stores/settings';
import { DialogTitle } from '~/components/ui/Dialog';
import { AvatarDropdown } from './AvatarDropdown';
// Import all tab components
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
import DataTab from '~/components/@settings/tabs/data/DataTab';
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
interface ControlPanelProps {
open: boolean;
onClose: () => void;
}
interface TabWithDevType extends TabVisibilityConfig {
isExtraDevTab?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
'tab-management': 'Configure visible tabs and their order',
};
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
// State
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
// Store values
const tabConfiguration = useStore(tabConfigurationStore);
const developerMode = useStore(developerModeStore);
const profile = useStore(profileStore) as Profile;
// Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Add visibleTabs logic using useMemo
const visibleTabs = useMemo(() => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, resetting to defaults');
resetTabConfiguration();
return [];
}
// In developer mode, show ALL tabs without restrictions
if (developerMode) {
// 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),
]);
// 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,
visible: true,
window: 'developer' as const,
order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
};
});
// Add Tab Management tile for developer mode
const tabManagementConfig: DevTabConfig = {
id: 'tab-management',
visible: true,
window: 'developer',
order: devTabs.length,
isExtraDevTab: true,
};
devTabs.push(tabManagementConfig);
return devTabs.sort((a, b) => a.order - b.order);
}
// In user mode, only show visible user tabs
const notificationsDisabled = profile?.preferences?.notifications === false;
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 in user preferences
if (tab.id === 'notifications' && notificationsDisabled) {
return false;
}
// 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);
}, [tabConfiguration, developerMode, profile?.preferences?.notifications]);
// Handlers
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
const handleDeveloperModeChange = (checked: boolean) => {
console.log('Developer mode changed:', checked);
setDeveloperMode(checked);
};
// Add effect to log developer mode changes
useEffect(() => {
console.log('Current developer mode:', developerMode);
}, [developerMode]);
const getTabComponent = (tabId: TabType | 'tab-management') => {
if (tabId === 'tab-management') {
return <TabManagement />;
}
switch (tabId) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
case 'task-manager':
return <TaskManagerTab />;
case 'service-status':
return <ServiceStatusTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'update':
return hasUpdate;
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'connection':
return hasConnectionIssues;
case 'debug':
return hasActiveWarnings;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'update':
return `New update available (v${currentVersion})`;
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'connection':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
case 'debug': {
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
const errors = activeIssues.filter((i) => i.type === 'error').length;
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
}
default:
return '';
}
};
const handleTabClick = (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
setShowTabManagement(false);
// Acknowledge notifications based on tab
switch (tabId) {
case 'update':
acknowledgeUpdate();
break;
case 'features':
acknowledgeAllFeatures();
break;
case 'notifications':
markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
acknowledgeAllIssues();
break;
}
// Clear loading state after a delay
setTimeout(() => setLoadingTab(null), 500);
};
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
className="relative z-[101]"
>
<motion.div
className={classNames(
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{activeTab || showTabManagement ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
) : (
<motion.div
className="w-7 h-7"
initial={{ rotate: -5 }}
animate={{ rotate: 5 }}
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2,
ease: 'easeInOut',
}}
>
<div className="w-full h-full flex items-center justify-center bg-gray-100/50 dark:bg-gray-800/50 rounded-full">
<div className="i-ph:robot-fill w-5 h-5 text-gray-400 dark:text-gray-400 transition-colors" />
</div>
</motion.div>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center gap-6">
{/* Developer Mode Controls */}
<div className="flex items-center gap-6">
{/* Mode Toggle */}
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
<Switch
id="developer-mode"
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full',
'bg-gray-200 dark:bg-gray-700',
'data-[state=checked]:bg-purple-500',
'transition-colors duration-200',
)}
>
<span className="sr-only">Toggle developer mode</span>
<span
className={classNames(
'inline-block h-4 w-4 transform rounded-full bg-white',
'transition duration-200',
'translate-x-1 data-[state=checked]:translate-x-6',
)}
/>
</Switch>
<div className="flex items-center gap-2">
<label
htmlFor="developer-mode"
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
>
{developerMode ? 'Developer Mode' : 'User Mode'}
</label>
</div>
</div>
</div>
{/* Avatar and Dropdown */}
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
<AvatarDropdown onSelectTab={handleTabClick} />
</div>
{/* Close Button */}
<button
onClick={onClose}
className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6"
>
{showTabManagement ? (
<TabManagement />
) : activeTab ? (
getTabComponent(activeTab)
) : (
<motion.div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
<AnimatePresence mode="popLayout">
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
mass: 0.8,
duration: 0.3,
}}
className="aspect-[1.5/1]"
>
<TabTile
tab={tab}
onClick={() => handleTabClick(tab.id as TabType)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
className="h-full"
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@ -0,0 +1,88 @@
import type { TabType } from './types';
export const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:star-fill',
data: 'i-ph:database-fill',
'cloud-providers': 'i-ph:cloud-fill',
'local-providers': 'i-ph:desktop-fill',
'service-status': 'i-ph:activity-bold',
connection: 'i-ph:wifi-high-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
'task-manager': 'i-ph:chart-line-fill',
'tab-management': 'i-ph:squares-four-fill',
};
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
'service-status': 'Service Status',
connection: 'Connection',
debug: 'Debug',
'event-logs': 'Event Logs',
update: 'Updates',
'task-manager': 'Task Manager',
'tab-management': 'Tab Management',
};
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
'tab-management': 'Configure visible tabs and their order',
};
export const DEFAULT_TAB_CONFIG = [
// User Window Tabs (Always visible by default)
{ id: 'features', visible: true, window: 'user' as const, order: 0 },
{ id: 'data', visible: true, window: 'user' as const, order: 1 },
{ id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
{ id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
{ id: 'connection', visible: true, window: 'user' as const, order: 4 },
{ id: 'notifications', visible: true, window: 'user' as const, order: 5 },
{ id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
// User Window Tabs (In dropdown, initially hidden)
{ id: 'profile', visible: false, window: 'user' as const, order: 7 },
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
{ id: 'service-status', visible: false, window: 'user' as const, order: 10 },
// User Window Tabs (Hidden, controlled by TaskManagerTab)
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
{ id: 'update', visible: false, window: 'user' as const, order: 12 },
// Developer Window Tabs (All visible by default)
{ id: 'features', visible: true, window: 'developer' as const, order: 0 },
{ id: 'data', visible: true, window: 'developer' as const, order: 1 },
{ id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
{ id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
{ id: 'connection', visible: true, window: 'developer' as const, order: 4 },
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
{ id: 'profile', visible: true, window: 'developer' as const, order: 7 },
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
{ id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
{ id: 'update', visible: true, window: 'developer' as const, order: 12 },
];

View File

@ -10,12 +10,13 @@ export type TabType =
| 'data'
| 'cloud-providers'
| 'local-providers'
| 'service-status'
| 'connection'
| 'debug'
| 'event-logs'
| 'update'
| 'task-manager'
| 'service-status';
| 'tab-management';
export type WindowType = 'user' | 'developer';
@ -46,14 +47,23 @@ export interface SettingItem {
export interface TabVisibilityConfig {
id: TabType;
visible: boolean;
window: 'user' | 'developer';
window: WindowType;
order: number;
isExtraDevTab?: boolean;
locked?: boolean;
}
export interface DevTabConfig extends TabVisibilityConfig {
window: 'developer';
}
export interface UserTabConfig extends TabVisibilityConfig {
window: 'user';
}
export interface TabWindowConfig {
userTabs: TabVisibilityConfig[];
developerTabs: TabVisibilityConfig[];
userTabs: UserTabConfig[];
developerTabs: DevTabConfig[];
}
export const TAB_LABELS: Record<TabType, string> = {
@ -61,47 +71,18 @@ export const TAB_LABELS: Record<TabType, string> = {
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data',
data: 'Data Management',
'cloud-providers': 'Cloud Providers',
'local-providers': 'Local Providers',
connection: 'Connection',
'service-status': 'Service Status',
connection: 'Connections',
debug: 'Debug',
'event-logs': 'Event Logs',
update: 'Update',
update: 'Updates',
'task-manager': 'Task Manager',
'service-status': 'Service Status',
'tab-management': 'Tab Management',
};
export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [
// User Window Tabs (Visible by default)
{ id: 'features', visible: true, window: 'user', order: 0 },
{ id: 'data', visible: true, window: 'user', order: 1 },
{ id: 'cloud-providers', visible: true, window: 'user', order: 2 },
{ id: 'local-providers', visible: true, window: 'user', order: 3 },
{ id: 'connection', visible: true, window: 'user', order: 4 },
{ id: 'debug', visible: true, window: 'user', order: 5 },
// User Window Tabs (Hidden by default)
{ id: 'profile', visible: false, window: 'user', order: 6 },
{ id: 'settings', visible: false, window: 'user', order: 7 },
{ id: 'notifications', visible: false, window: 'user', order: 8 },
{ id: 'event-logs', visible: false, window: 'user', order: 9 },
{ id: 'update', visible: false, window: 'user', order: 10 },
{ id: 'service-status', visible: false, window: 'user', order: 11 },
// Developer Window Tabs (All visible by default)
{ id: 'features', visible: true, window: 'developer', order: 0 },
{ id: 'data', visible: true, window: 'developer', order: 1 },
{ id: 'cloud-providers', visible: true, window: 'developer', order: 2 },
{ id: 'local-providers', visible: true, window: 'developer', order: 3 },
{ id: 'connection', visible: true, window: 'developer', order: 4 },
{ id: 'debug', visible: true, window: 'developer', order: 5 },
{ id: 'task-manager', visible: true, window: 'developer', order: 6 },
{ id: 'settings', visible: true, window: 'developer', order: 7 },
{ id: 'notifications', visible: true, window: 'developer', order: 8 },
{ id: 'service-status', visible: true, window: 'developer', order: 9 },
];
export const categoryLabels: Record<SettingCategory, string> = {
profile: 'Profile & Account',
file_sharing: 'File Sharing',
@ -119,3 +100,15 @@ export const categoryIcons: Record<SettingCategory, string> = {
services: 'i-ph:cube',
preferences: 'i-ph:sliders',
};
export interface Profile {
username?: string;
bio?: string;
avatar?: string;
preferences?: {
notifications?: boolean;
theme?: 'light' | 'dark' | 'system';
language?: string;
timezone?: string;
};
}

View File

@ -0,0 +1,14 @@
// Core exports
export { ControlPanel } from './core/ControlPanel';
export type { TabType, TabVisibilityConfig } from './core/types';
// Constants
export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants';
// Shared components
export { TabTile } from './shared/components/TabTile';
export { TabManagement } from './shared/components/TabManagement';
// Utils
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
export * from './utils/animations';

View File

@ -1,8 +1,8 @@
import { useDrag, useDrop } from 'react-dnd';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.types';
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
import { TAB_LABELS } from '~/components/@settings/core/types';
import { Switch } from '~/components/ui/Switch';
interface DraggableTabListProps {

View File

@ -0,0 +1,259 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { Switch } from '@radix-ui/react-switch';
import { classNames } from '~/utils/classNames';
import { tabConfigurationStore } from '~/lib/stores/settings';
import { TAB_LABELS } from '~/components/@settings/core/constants';
import type { TabType } from '~/components/@settings/core/types';
import { toast } from 'react-toastify';
import { TbLayoutGrid } from 'react-icons/tb';
// Define tab icons mapping
const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:star-fill',
data: 'i-ph:database-fill',
'cloud-providers': 'i-ph:cloud-fill',
'local-providers': 'i-ph:desktop-fill',
'service-status': 'i-ph:activity-fill',
connection: 'i-ph:wifi-high-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
'task-manager': 'i-ph:chart-line-fill',
'tab-management': 'i-ph:squares-four-fill',
};
// Define which tabs are default in user mode
const DEFAULT_USER_TABS: TabType[] = [
'features',
'data',
'cloud-providers',
'local-providers',
'connection',
'notifications',
'event-logs',
];
// Define which tabs can be added to user mode
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
// All available tabs for user mode
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
export const TabManagement = () => {
const [searchQuery, setSearchQuery] = useState('');
const tabConfiguration = useStore(tabConfigurationStore);
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
// Get current tab configuration
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
// If tab doesn't exist in configuration, create it
if (!currentTab) {
const newTab = {
id: tabId,
visible: checked,
window: 'user' as const,
order: tabConfiguration.userTabs.length,
};
const updatedTabs = [...tabConfiguration.userTabs, newTab];
tabConfigurationStore.set({
...tabConfiguration,
userTabs: updatedTabs,
});
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
return;
}
// Check if tab can be enabled in user mode
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
if (!canBeEnabled && checked) {
toast.error('This tab cannot be enabled in user mode');
return;
}
// Update tab visibility
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, visible: checked };
}
return tab;
});
// Update store
tabConfigurationStore.set({
...tabConfiguration,
userTabs: updatedTabs,
});
// Show success message
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
};
// Create a map of existing tab configurations
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
// Generate the complete list of tabs, including those not in the configuration
const allTabs = ALL_USER_TABS.map((tabId) => {
return (
tabConfigMap.get(tabId) || {
id: tabId,
visible: false,
window: 'user' as const,
order: -1,
}
);
});
// Filter tabs based on search query
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
return (
<div className="space-y-6">
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header */}
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
'text-purple-500',
)}
>
<TbLayoutGrid className="w-5 h-5" />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
</div>
</div>
{/* Search */}
<div className="relative w-64">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tabs..."
className={classNames(
'w-full pl-10 pr-4 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
/>
</div>
</div>
{/* Tab Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredTabs.map((tab, index) => (
<motion.div
key={tab.id}
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
{/* Status Badges */}
<div className="absolute top-2 right-2 flex gap-1">
{DEFAULT_USER_TABS.includes(tab.id) && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium">
Default
</span>
)}
{OPTIONAL_USER_TABS.includes(tab.id) && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">
Optional
</span>
)}
</div>
<div className="flex items-start gap-4 p-4">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
</div>
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{TAB_LABELS[tab.id]}
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
</p>
</div>
<Switch
checked={tab.visible}
onCheckedChange={(checked) => handleTabVisibilityChange(tab.id, checked)}
disabled={!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id)}
className={classNames(
'relative inline-flex h-5 w-9 items-center rounded-full',
'transition-colors duration-200',
tab.visible ? 'bg-purple-500' : 'bg-bolt-elements-background-depth-4',
{
'opacity-50 cursor-not-allowed':
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
},
)}
/>
</div>
</div>
</div>
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: tab.visible ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
</div>
</motion.div>
</div>
);
};

View File

@ -0,0 +1,162 @@
import { motion } from 'framer-motion';
import * as Tooltip from '@radix-ui/react-tooltip';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
interface TabTileProps {
tab: TabVisibilityConfig;
onClick?: () => void;
isActive?: boolean;
hasUpdate?: boolean;
statusMessage?: string;
description?: string;
isLoading?: boolean;
className?: string;
}
export const TabTile = ({
tab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
className,
}: TabTileProps) => {
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<motion.div
onClick={onClick}
className={classNames(
'relative flex flex-col items-center p-6 rounded-xl',
'w-full h-full min-h-[160px]',
'bg-white dark:bg-[#141414]',
'border border-[#E5E5E5] dark:border-[#333333]',
'group',
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
'hover:border-purple-200 dark:hover:border-purple-900/30',
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
isLoading ? 'cursor-wait opacity-70' : '',
className || '',
)}
>
{/* Main Content */}
<div className="flex flex-col items-center justify-center flex-1 w-full">
{/* Icon */}
<motion.div
className={classNames(
'relative',
'w-14 h-14',
'flex items-center justify-center',
'rounded-xl',
'bg-gray-100 dark:bg-gray-800',
'ring-1 ring-gray-200 dark:ring-gray-700',
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
)}
>
<motion.div
className={classNames(
TAB_ICONS[tab.id],
'w-8 h-8',
'text-gray-600 dark:text-gray-300',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)}
/>
</motion.div>
{/* Label and Description */}
<div className="flex flex-col items-center mt-5 w-full">
<h3
className={classNames(
'text-[15px] font-medium leading-snug mb-2',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)}
>
{TAB_LABELS[tab.id]}
</h3>
{description && (
<p
className={classNames(
'text-[13px] leading-relaxed',
'text-gray-500 dark:text-gray-400',
'max-w-[85%]',
'text-center',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
)}
>
{description}
</p>
)}
</div>
</div>
{/* Status Indicator */}
{hasUpdate && (
<motion.div
className={classNames(
'absolute top-3 right-3',
'w-2.5 h-2.5 rounded-full',
'bg-purple-500',
'ring-4 ring-purple-500',
)}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.5 }}
/>
)}
{/* Loading Overlay */}
{isLoading && (
<motion.div
className={classNames(
'absolute inset-0 rounded-xl z-10',
'bg-white dark:bg-black',
'flex items-center justify-center',
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<motion.div
className={classNames('w-8 h-8 rounded-full', 'border-2 border-purple-500', 'border-t-purple-500')}
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
/>
</motion.div>
)}
</motion.div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage || TAB_LABELS[tab.id]}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import type { GitHubAuthState } from '~/components/settings/connections/types/GitHub';
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
import Cookies from 'js-cookie';
import { getLocalStorage } from '~/lib/persistence';

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import type { GitHubRepoInfo } from '~/components/settings/connections/types/GitHub';
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
import { GitBranch } from '@phosphor-icons/react';
interface GitHubBranch {

View File

@ -131,6 +131,13 @@ interface WebAppInfo {
gitInfo: GitInfo;
}
// Add Ollama service status interface
interface OllamaServiceStatus {
isRunning: boolean;
lastChecked: Date;
error?: string;
}
const DependencySection = ({
title,
deps,
@ -146,7 +153,17 @@ const DependencySection = ({
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<CollapsibleTrigger
className={classNames(
'flex w-full items-center justify-between p-4',
'bg-white dark:bg-[#0A0A0A]',
'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
'transition-colors duration-200',
'first:rounded-t-lg last:rounded-b-lg',
{ 'hover:rounded-lg': !isOpen },
)}
>
<div className="flex items-center gap-3">
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
<span className="text-base text-bolt-elements-textPrimary">
@ -157,15 +174,22 @@ const DependencySection = ({
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[200px] w-full p-4">
<div className="space-y-2 pl-7">
<ScrollArea
className={classNames(
'h-[200px] w-full',
'bg-white dark:bg-[#0A0A0A]',
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
'last:rounded-b-lg last:border-b-0',
)}
>
<div className="space-y-2 p-4">
{deps.map((dep) => (
<div key={dep.name} className="flex items-center justify-between text-sm">
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
@ -182,6 +206,10 @@ const DependencySection = ({
export default function DebugTab() {
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
isRunning: false,
lastChecked: new Date(),
});
const [loading, setLoading] = useState({
systemInfo: false,
webAppInfo: false,
@ -259,7 +287,8 @@ export default function DebugTab() {
return undefined;
}
const interval = setInterval(async () => {
// Initial fetch
const fetchGitInfo = async () => {
try {
const response = await fetch('/api/system/git-info');
const updatedGitInfo = (await response.json()) as GitInfo;
@ -269,21 +298,27 @@ export default function DebugTab() {
return null;
}
// Only update if the data has changed
if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
return prev;
}
return {
...prev,
gitInfo: updatedGitInfo,
};
});
} catch (error) {
console.error('Failed to refresh git info:', error);
console.error('Failed to fetch git info:', error);
}
}, 5000);
const cleanup = () => {
clearInterval(interval);
};
return cleanup;
fetchGitInfo();
// Refresh every 5 minutes instead of every second
const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [openSections.webapp]);
const getSystemInfo = async () => {
@ -616,11 +651,68 @@ export default function DebugTab() {
}
};
// Add Ollama health check function
const checkOllamaHealth = async () => {
try {
const response = await fetch('http://127.0.0.1:11434/api/version');
const isHealthy = response.ok;
setOllamaStatus({
isRunning: isHealthy,
lastChecked: new Date(),
error: isHealthy ? undefined : 'Ollama service is not responding',
});
return isHealthy;
} catch {
setOllamaStatus({
isRunning: false,
lastChecked: new Date(),
error: 'Failed to connect to Ollama service',
});
return false;
}
};
// Add Ollama health check effect
useEffect(() => {
const checkHealth = async () => {
await checkOllamaHealth();
};
checkHealth();
const interval = setInterval(checkHealth, 30000); // Check every 30 seconds
return () => clearInterval(interval);
}, []);
return (
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
{/* Quick Stats Banner */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-gradient-to-br from-purple-500/10 to-purple-500/5 border border-purple-500/20">
{/* Add Ollama Service Status Card */}
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Ollama Service</div>
<div className="flex items-center gap-2 mt-2">
<div
className={classNames(
'w-2 h-2 rounded-full animate-pulse',
ollamaStatus.isRunning ? 'bg-green-500' : 'bg-red-500',
)}
/>
<span
className={classNames('text-sm font-medium', ollamaStatus.isRunning ? 'text-green-500' : 'text-red-500')}
>
{ollamaStatus.isRunning ? 'Running' : 'Not Running'}
</span>
</div>
<div className="text-xs text-bolt-elements-textSecondary mt-2">
Last checked: {ollamaStatus.lastChecked.toLocaleTimeString()}
</div>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
{systemInfo?.memory.percentage}%
@ -628,7 +720,7 @@ export default function DebugTab() {
<Progress value={systemInfo?.memory.percentage || 0} className="mt-2" />
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-blue-500/10 to-blue-500/5 border border-blue-500/20">
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) + 's' : '-'}
@ -638,7 +730,7 @@ export default function DebugTab() {
</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-500/5 border border-green-500/20">
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
{systemInfo?.network.downlink || '-'} Mbps
@ -646,7 +738,7 @@ export default function DebugTab() {
<div className="text-xs text-bolt-elements-textSecondary mt-2">RTT: {systemInfo?.network.rtt || '-'} ms</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-red-500/10 to-red-500/5 border border-red-500/20">
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">{errorLogs.length}</div>
</div>
@ -659,10 +751,11 @@ export default function DebugTab() {
disabled={loading.systemInfo}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
'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',
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
)}
>
@ -679,10 +772,11 @@ export default function DebugTab() {
disabled={loading.performance}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
'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',
{ 'opacity-50 cursor-not-allowed': loading.performance },
)}
>
@ -699,10 +793,11 @@ export default function DebugTab() {
disabled={loading.errors}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
'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',
{ 'opacity-50 cursor-not-allowed': loading.errors },
)}
>
@ -719,10 +814,11 @@ export default function DebugTab() {
disabled={loading.webAppInfo}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
'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',
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
)}
>
@ -738,10 +834,11 @@ export default function DebugTab() {
onClick={exportDebugInfo}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
'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',
)}
>
<div className="i-ph:download w-4 h-4" />
@ -1152,7 +1249,7 @@ export default function DebugTab() {
{webAppInfo && (
<div className="mt-6">
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
<div className="space-y-2 bg-gray-50 dark:bg-[#1A1A1A] rounded-lg">
<div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />

View File

@ -0,0 +1,613 @@
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';
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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>Model: {details.model}</span>
<span></span>
<span>Tokens: {details.totalTokens}</span>
<span></span>
<span>Duration: {details.duration}ms</span>
</div>
{details.prompt && (
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
{details.prompt}
</pre>
</div>
)}
{details.response && (
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
{details.response}
</pre>
</div>
)}
</div>
);
}
if (log.category === 'api') {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
<span></span>
<span>Status: {details.statusCode}</span>
<span></span>
<span>Duration: {details.duration}ms</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
{details.request && (
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
{JSON.stringify(details.request, null, 2)}
</pre>
</div>
)}
{details.response && (
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
{JSON.stringify(details.response, null, 2)}
</pre>
</div>
)}
{details.error && (
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-red-500">Error:</div>
<pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
{JSON.stringify(details.error, null, 2)}
</pre>
</div>
)}
</div>
);
}
return (
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
{JSON.stringify(details, null, 2)}
</pre>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col gap-2',
'rounded-lg p-4',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
style.bg,
'transition-all duration-200',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<span className={classNames('text-lg', style.icon, style.color)} />
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
{log.details && (
<>
<button
onClick={() => 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
</button>
{localExpanded && renderDetails(log.details)}
</>
)}
<div className="flex items-center gap-2">
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
{log.level}
</div>
{log.category && (
<div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
{log.category}
</div>
)}
</div>
</div>
</div>
{showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
</div>
</motion.div>
);
};
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<HTMLDivElement>(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 export logs handler
const handleExportLogs = useCallback(() => {
const startTime = performance.now();
try {
const exportData = {
timestamp: new Date().toISOString(),
logs: filteredLogs,
filters: {
level: selectedLevel,
searchQuery,
},
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bolt-logs-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const duration = performance.now() - startTime;
logStore.logSuccess('Logs exported successfully', {
type: 'export',
message: `Successfully exported ${filteredLogs.length} logs`,
component: 'EventLogsTab',
exportedCount: filteredLogs.length,
filters: {
level: selectedLevel,
searchQuery,
},
duration,
});
} catch (error) {
logStore.logError('Failed to export logs', error, {
type: 'export_error',
message: 'Failed to export logs',
component: 'EventLogsTab',
});
}
}, [filteredLogs, selectedLevel, searchQuery]);
// 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);
return (
<div className="flex h-full flex-col gap-6">
<div className="flex items-center justify-between">
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
<DropdownMenu.Trigger asChild>
<button
className={classNames(
'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',
)}
>
<span
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
style={{ color: selectedLevelOption?.color }}
/>
{selectedLevelOption?.label || 'All Types'}
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
sideOffset={5}
align="start"
side="bottom"
>
{logLevelOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onClick={() => handleLevelFilterChange(option.value)}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
style={{ color: option.color }}
/>
</div>
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={showTimestamps}
onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
className="data-[state=checked]:bg-purple-500"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={use24Hour}
onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
className="data-[state=checked]:bg-purple-500"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={autoExpand}
onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
className="data-[state=checked]:bg-purple-500"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
</div>
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
<button
onClick={handleRefresh}
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',
{ 'animate-spin': isRefreshing },
)}
>
<span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
Refresh
</button>
<button
onClick={handleExportLogs}
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',
)}
>
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
Export
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="relative">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => 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',
)}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
</div>
</div>
{filteredLogs.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col items-center justify-center gap-4',
'rounded-lg p-8 text-center',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
</div>
</motion.div>
) : (
filteredLogs.map((log) => (
<LogEntryItem
key={log.id}
log={log}
isExpanded={autoExpand}
use24Hour={use24Hour}
showTimestamp={showTimestamps}
/>
))
)}
</div>
</div>
);
}

View File

@ -111,44 +111,66 @@ export default function FeaturesTab() {
isLatestBranch,
contextOptimizationEnabled,
eventLogs,
isLocalModel,
setAutoSelectTemplate,
enableLatestBranch,
enableContextOptimization,
setEventLogs,
enableLocalModels,
setPromptId,
promptId,
} = useSettings();
// Enable features by default on first load
React.useEffect(() => {
// Only enable if they haven't been explicitly set before
if (isLatestBranch === undefined) {
enableLatestBranch(true);
}
if (contextOptimizationEnabled === undefined) {
enableContextOptimization(true);
}
if (autoSelectTemplate === undefined) {
setAutoSelectTemplate(true);
}
if (eventLogs === undefined) {
setEventLogs(true);
}
}, []); // Only run once on component mount
const handleToggleFeature = useCallback(
(id: string, enabled: boolean) => {
switch (id) {
case 'latestBranch':
case 'latestBranch': {
enableLatestBranch(enabled);
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'autoSelectTemplate':
}
case 'autoSelectTemplate': {
setAutoSelectTemplate(enabled);
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'contextOptimization':
}
case 'contextOptimization': {
enableContextOptimization(enabled);
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'eventLogs':
}
case 'eventLogs': {
setEventLogs(enabled);
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'localModels':
enableLocalModels(enabled);
toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`);
break;
}
default:
break;
}
},
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs, enableLocalModels],
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
);
const features = {
@ -159,7 +181,7 @@ export default function FeaturesTab() {
description: 'Get the latest updates from the main branch',
icon: 'i-ph:git-branch',
enabled: isLatestBranch,
tooltip: 'Enable to receive updates from the main development branch',
tooltip: 'Enabled by default to receive updates from the main development branch',
},
{
id: 'autoSelectTemplate',
@ -167,7 +189,7 @@ export default function FeaturesTab() {
description: 'Automatically select starter template',
icon: 'i-ph:selection',
enabled: autoSelectTemplate,
tooltip: 'Automatically select the most appropriate starter template',
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
},
{
id: 'contextOptimization',
@ -175,7 +197,7 @@ export default function FeaturesTab() {
description: 'Optimize context for better responses',
icon: 'i-ph:brain',
enabled: contextOptimizationEnabled,
tooltip: 'Enable context optimization for improved AI responses',
tooltip: 'Enabled by default for improved AI responses',
},
{
id: 'eventLogs',
@ -183,30 +205,19 @@ export default function FeaturesTab() {
description: 'Enable detailed event logging and history',
icon: 'i-ph:list-bullets',
enabled: eventLogs,
tooltip: 'Record detailed logs of system events and user actions',
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
},
],
beta: [],
experimental: [
{
id: 'localModels',
title: 'Experimental Providers',
description: 'Enable experimental providers like Ollama, LMStudio, and OpenAILike',
icon: 'i-ph:robot',
enabled: isLocalModel,
experimental: true,
tooltip: 'Try out new AI providers and models in development',
},
],
};
return (
<div className="flex flex-col gap-8">
<FeatureSection
title="Stable Features"
title="Core Features"
features={features.stable}
icon="i-ph:check-circle"
description="Production-ready features that have been thoroughly tested"
description="Essential features that are enabled by default for optimal performance"
onToggleFeature={handleToggleFeature}
/>
@ -220,16 +231,6 @@ export default function FeaturesTab() {
/>
)}
{features.experimental.length > 0 && (
<FeatureSection
title="Experimental Features"
features={features.experimental}
icon="i-ph:flask"
description="Features in early development that may be unstable or require additional setup"
onToggleFeature={handleToggleFeature}
/>
)}
<motion.div
layout
className={classNames(

View File

@ -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<HTMLInputElement>) => {
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 (
<div className="max-w-2xl mx-auto">
<div className="space-y-6">
{/* Personal Information Section */}
<div>
{/* Avatar Upload */}
<div className="flex items-start gap-6 mb-8">
<div
className={classNames(
'w-24 h-24 rounded-full overflow-hidden',
'bg-gray-100 dark:bg-gray-800/50',
'flex items-center justify-center',
'ring-1 ring-gray-200 dark:ring-gray-700',
'relative group',
'transition-all duration-300 ease-out',
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
'hover:shadow-lg hover:shadow-purple-500/10',
)}
>
{profile.avatar ? (
<img
src={profile.avatar}
alt="Profile"
className={classNames(
'w-full h-full object-cover',
'transition-all duration-300 ease-out',
'group-hover:scale-105 group-hover:brightness-90',
)}
/>
) : (
<div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
)}
<label
className={classNames(
'absolute inset-0',
'flex items-center justify-center',
'bg-black/0 group-hover:bg-black/40',
'cursor-pointer transition-all duration-300 ease-out',
isUploading ? 'cursor-wait' : '',
)}
>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isUploading}
/>
{isUploading ? (
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
) : (
<div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
)}
</label>
</div>
<div className="flex-1 pt-1">
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
Profile Picture
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
</div>
</div>
{/* Username Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
<div className="relative group">
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
<div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<input
type="text"
value={profile.username}
onChange={(e) => 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"
/>
</div>
</div>
{/* Bio Input */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
<div className="relative group">
<div className="absolute left-3.5 top-3">
<div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
</div>
<textarea
value={profile.bio}
onChange={(e) => handleProfileUpdate('bio', 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',
'resize-none',
'h-32',
)}
placeholder="Tell us about yourself"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,718 @@
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<ProviderName, IconType> = {
Ollama: BsRobot,
LMStudio: BsRobot,
OpenAILike: TbBrandOpenai,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
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<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState(false);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(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;
// Get environment URL safely
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
console.log(`Checking env URL for ${key}:`, {
envKey,
envUrl,
currentBaseUrl: provider.settings.baseUrl,
});
// If there's an environment URL and no base URL set, update it
if (envUrl && !provider.settings.baseUrl) {
console.log(`Setting base URL for ${key} from env:`, envUrl);
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<boolean> => {
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 }) => (
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<div className="flex items-center gap-1">
<div className="i-ph:code text-purple-500" />
<span>{model.digest.substring(0, 7)}</span>
</div>
{model.details && (
<>
<div className="flex items-center gap-1">
<div className="i-ph:database text-purple-500" />
<span>{model.details.parameter_size}</span>
</div>
<div className="flex items-center gap-1">
<div className="i-ph:cube text-purple-500" />
<span>{model.details.quantization_level}</span>
</div>
</>
)}
</div>
);
// Update model actions to not use Tooltip
const ModelActions = ({
model,
onUpdate,
onDelete,
}: {
model: OllamaModel;
onUpdate: () => void;
onDelete: () => void;
}) => (
<div className="flex items-center gap-2">
<motion.button
onClick={onUpdate}
disabled={model.status === 'updating'}
className={classNames(
'rounded-lg p-2',
'bg-purple-500/10 text-purple-500',
'hover:bg-purple-500/20',
'transition-all duration-200',
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Update model"
>
{model.status === 'updating' ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-sm">Updating...</span>
</div>
) : (
<div className="i-ph:arrows-clockwise text-lg" />
)}
</motion.button>
<motion.button
onClick={onDelete}
disabled={model.status === 'updating'}
className={classNames(
'rounded-lg p-2',
'bg-red-500/10 text-red-500',
'hover:bg-red-500/20',
'transition-all duration-200',
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Delete model"
>
<div className="i-ph:trash text-lg" />
</motion.button>
</div>
);
return (
<div
className={classNames(
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
role="region"
aria-label="Local Providers Configuration"
>
<motion.div
className="space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header section */}
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
<div className="flex items-center gap-3">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-purple-500/10 text-purple-500',
)}
whileHover={{ scale: 1.05 }}
>
<BiChip className="w-6 h-6" />
</motion.div>
<div>
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
<Switch
checked={categoryEnabled}
onCheckedChange={handleToggleCategory}
aria-label="Toggle all local providers"
/>
</div>
</div>
{/* Ollama Section */}
{filteredProviders
.filter((provider) => provider.name === 'Ollama')
.map((provider) => (
<motion.div
key={provider.name}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-xl',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200 p-5',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.01 }}
>
{/* Provider Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<motion.div
className={classNames(
'w-12 h-12 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1, rotate: 5 }}
>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-7 h-7',
'aria-label': `${provider.name} icon`,
})}
</motion.div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-1">
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
</p>
</div>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
aria-label={`Toggle ${provider.name} provider`}
/>
</div>
{/* Ollama Models Section */}
{provider.settings.enabled && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:cube-duotone text-purple-500" />
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
</div>
{isLoadingModels ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
</div>
) : (
<span className="text-sm text-bolt-elements-textSecondary">
{ollamaModels.length} models available
</span>
)}
</div>
<div className="space-y-3">
{isLoadingModels ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
/>
))}
</div>
) : ollamaModels.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
<p>No models installed yet</p>
<p className="text-sm">Install your first model below</p>
</div>
) : (
ollamaModels.map((model) => (
<motion.div
key={model.name}
className={classNames(
'p-4 rounded-xl',
'bg-bolt-elements-background-depth-3',
'hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
)}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
<ModelStatusBadge status={model.status} />
</div>
<ModelDetails model={model} />
</div>
<ModelActions
model={model}
onUpdate={() => handleUpdateOllamaModel(model.name)}
onDelete={() => {
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
handleDeleteOllamaModel(model.name);
}
}}
/>
</div>
{model.progress && (
<div className="mt-3">
<Progress
value={Math.round((model.progress.current / model.progress.total) * 100)}
className="h-1"
/>
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
<span>{model.progress.status}</span>
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
</div>
</div>
)}
</motion.div>
))
)}
</div>
{/* Model Installation Section */}
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
</motion.div>
)}
</motion.div>
))}
{/* Other Providers Section */}
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredProviders
.filter((provider) => provider.name !== 'Ollama')
.map((provider, index) => (
<motion.div
key={provider.name}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-xl',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200 p-5',
'relative overflow-hidden group',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.01 }}
>
{/* Provider Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<motion.div
className={classNames(
'w-12 h-12 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1, rotate: 5 }}
>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-7 h-7',
'aria-label': `${provider.name} icon`,
})}
</motion.div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<div className="flex gap-1">
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
Local
</span>
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
Configurable
</span>
)}
</div>
</div>
<p className="text-sm text-bolt-elements-textSecondary mt-1">
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
</p>
</div>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
aria-label={`Toggle ${provider.name} provider`}
/>
</div>
{/* URL Configuration Section */}
<AnimatePresence>
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4"
>
<div className="flex flex-col gap-2">
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
{editingProvider === provider.name ? (
<input
type="text"
defaultValue={provider.settings.baseUrl}
placeholder={`Enter ${provider.name} base URL`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateBaseUrl(provider, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingProvider(null);
}
}}
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
autoFocus
/>
) : (
<div
onClick={() => 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',
)}
>
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:link text-sm" />
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
);
}
// 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 (
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
{config.label}
</span>
);
}

View File

@ -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 (
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
</svg>
);
}
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<InstallProgress | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [models, setModels] = useState<ModelInfo[]>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between pt-6">
<div className="flex items-center gap-3">
<OllamaIcon className="w-8 h-8 text-purple-500" />
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
</div>
</div>
<motion.button
onClick={handleCheckUpdates}
disabled={isChecking}
className={classNames(
'px-4 py-2 rounded-lg',
'bg-purple-500/10 text-purple-500',
'hover:bg-purple-500/20',
'transition-all duration-200',
'flex items-center gap-2',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isChecking ? (
<div className="i-ph:spinner-gap-bold animate-spin" />
) : (
<div className="i-ph:arrows-clockwise" />
)}
Check Updates
</motion.button>
</div>
<div className="flex gap-4">
<div className="flex-1">
<div className="space-y-1">
<input
type="text"
className={classNames(
'w-full px-4 py-3 rounded-xl',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
placeholder="Search models or enter custom model name..."
value={searchQuery || modelString}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
setModelString(value);
}}
disabled={isInstalling}
/>
<p className="text-xs text-bolt-elements-textTertiary px-1">
Browse models at{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline inline-flex items-center gap-0.5"
>
ollama.com/library
<div className="i-ph:arrow-square-out text-[10px]" />
</a>{' '}
and copy model names to install
</p>
</div>
</div>
<motion.button
onClick={() => 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 ? (
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin" />
<span>Installing...</span>
</div>
) : (
<div className="flex items-center gap-2">
<OllamaIcon className="w-4 h-4" />
<span>Install Model</span>
</div>
)}
</motion.button>
</div>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => {
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}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-2">
{filteredModels.map((model) => (
<motion.div
key={model.name}
className={classNames(
'flex items-start gap-2 p-3 rounded-lg',
'bg-bolt-elements-background-depth-3',
'hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
'relative group',
)}
>
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="flex items-start justify-between">
<div>
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
</div>
<div className="text-right">
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
{model.installedVersion && (
<div className="mt-0.5 flex flex-col items-end gap-0.5">
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
{model.needsUpdate && model.latestVersion && (
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{model.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
>
{tag}
</span>
))}
</div>
<div className="flex gap-2">
{model.installedVersion ? (
model.needsUpdate ? (
<motion.button
onClick={() => 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 }}
>
<div className="i-ph:arrows-clockwise text-xs" />
Update
</motion.button>
) : (
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
)
) : (
<motion.button
onClick={() => 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 }}
>
<div className="i-ph:download text-xs" />
Install
</motion.button>
)}
</div>
</div>
</div>
</motion.div>
))}
</div>
{installProgress && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
<div className="flex items-center gap-4">
<span className="text-bolt-elements-textTertiary">
{installProgress.downloadedSize} / {installProgress.totalSize}
</span>
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
</div>
</div>
<Progress value={installProgress.progress} className="h-1" />
</motion.div>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -1,5 +1,5 @@
import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider';
import type { StatusCheckResult } from '~/components/settings/providers/service-status/types';
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<StatusCheckResult> {

View File

@ -4,7 +4,7 @@ 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/settings.types';
import type { UserProfile } from '~/components/@settings/core/types';
import { useStore } from '@nanostores/react';
import { shortcutsStore } from '~/lib/stores/settings';

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import * as React from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { classNames } from '~/utils/classNames';
import { Line } from 'react-chartjs-2';
import {
@ -12,6 +13,9 @@ import {
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);
@ -74,12 +78,6 @@ interface SystemMetrics {
lcp: number;
};
};
storage: {
total: number;
used: number;
free: number;
type: string;
};
health: {
score: number;
issues: string[];
@ -134,37 +132,46 @@ declare global {
}
}
const MAX_HISTORY_POINTS = 60; // 1 minute of history at 1s intervals
const BATTERY_THRESHOLD = 20; // Enable energy saver when battery below 20%
// Constants for update intervals
const UPDATE_INTERVALS = {
normal: {
metrics: 1000, // 1s
metrics: 1000, // 1 second
animation: 16, // ~60fps
},
energySaver: {
metrics: 5000, // 5s
metrics: 5000, // 5 seconds
animation: 32, // ~30fps
},
};
// Energy consumption estimates (milliwatts)
const ENERGY_COSTS = {
update: 2, // mW per update
apiCall: 5, // mW per API call
rendering: 1, // mW per render
};
// Constants for performance thresholds
const PERFORMANCE_THRESHOLDS = {
cpu: { warning: 70, critical: 90 },
memory: { warning: 80, critical: 95 },
fps: { warning: 30, critical: 15 },
loadTime: { warning: 3000, critical: 5000 },
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, higher power consumption',
description: 'Maximum performance with frequent updates',
settings: {
updateInterval: 1000,
updateInterval: UPDATE_INTERVALS.normal.metrics,
enableAnimations: true,
backgroundProcessing: true,
networkThrottling: false,
@ -172,7 +179,7 @@ const POWER_PROFILES: PowerProfile[] = [
},
{
name: 'Balanced',
description: 'Balance between performance and power saving',
description: 'Optimal balance between performance and energy efficiency',
settings: {
updateInterval: 2000,
enableAnimations: true,
@ -181,10 +188,10 @@ const POWER_PROFILES: PowerProfile[] = [
},
},
{
name: 'Power Saver',
description: 'Maximum power saving, reduced performance',
name: 'Energy Saver',
description: 'Maximum energy efficiency with reduced updates',
settings: {
updateInterval: 5000,
updateInterval: UPDATE_INTERVALS.energySaver.metrics,
enableAnimations: false,
backgroundProcessing: false,
networkThrottling: true,
@ -192,50 +199,271 @@ const POWER_PROFILES: PowerProfile[] = [
},
];
export default function TaskManagerTab() {
const [metrics, setMetrics] = useState<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 },
// 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,
},
storage: { total: 0, used: 0, free: 0, type: 'unknown' },
health: { score: 0, issues: [], suggestions: [] },
});
const [metricsHistory, setMetricsHistory] = useState<MetricsHistory>({
timestamps: [],
cpu: [],
memory: [],
battery: [],
network: [],
});
const [energySaverMode, setEnergySaverMode] = useState<boolean>(() => {
// Initialize from localStorage, default to false
const saved = localStorage.getItem('energySaverMode');
return saved ? JSON.parse(saved) : false;
});
},
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: [],
},
};
const [autoEnergySaver, setAutoEnergySaver] = useState<boolean>(() => {
// Initialize from localStorage, default to false
const saved = localStorage.getItem('autoEnergySaver');
return saved ? JSON.parse(saved) : false;
});
// 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),
};
const [energySavings, setEnergySavings] = useState<EnergySavings>({
// 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<SystemMetrics>(() => DEFAULT_METRICS_STATE);
const [metricsHistory, setMetricsHistory] = useState<MetricsHistory>(() => DEFAULT_METRICS_HISTORY);
const [energySaverMode, setEnergySaverMode] = useState<boolean>(false);
const [autoEnergySaver, setAutoEnergySaver] = useState<boolean>(false);
const [energySavings, setEnergySavings] = useState<EnergySavings>(() => ({
updatesReduced: 0,
timeInSaverMode: 0,
estimatedEnergySaved: 0,
});
const saverModeStartTime = useRef<number | null>(null);
const [selectedProfile, setSelectedProfile] = useState<PowerProfile>(POWER_PROFILES[1]); // Default to Balanced
}));
const [selectedProfile, setSelectedProfile] = useState<PowerProfile>(() => POWER_PROFILES[1]);
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
const saverModeStartTime = useRef<number | null>(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<Partial<SystemMetrics['performance']>> => {
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) => {
@ -296,48 +524,6 @@ export default function TaskManagerTab() {
return () => clearInterval(interval);
}, [updateEnergySavings]);
// Get detailed performance metrics
const getPerformanceMetrics = async (): Promise<Partial<SystemMetrics['performance']>> => {
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');
const resourceMetrics = {
total: resources.length,
size: resources.reduce((total, r) => total + (r as any).transferSize || 0, 0),
loadTime: Math.max(...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 {};
}
};
// Measure frame rate
const measureFrameRate = async (): Promise<number> => {
return new Promise((resolve) => {
@ -486,12 +672,6 @@ export default function TaskManagerTab() {
battery: batteryInfo,
network: networkInfo,
performance: performanceMetrics as SystemMetrics['performance'],
storage: {
total: 0,
used: 0,
free: 0,
type: 'unknown',
},
health: { score: 0, issues: [], suggestions: [] },
};
@ -597,23 +777,6 @@ export default function TaskManagerTab() {
};
}, [energySaverMode]);
// Initial update effect
useEffect((): (() => void) => {
// Initial update
updateMetrics();
// Set up intervals for live updates
const metricsInterval = setInterval(
updateMetrics,
energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
);
// Cleanup on unmount
return () => {
clearInterval(metricsInterval);
};
}, [energySaverMode]); // Re-create intervals when energy saver mode changes
const getUsageColor = (usage: number): string => {
if (usage > 80) {
return 'text-red-500';
@ -761,6 +924,7 @@ export default function TaskManagerTab() {
onChange={(e) => handleAutoEnergySaverChange(e.target.checked)}
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
/>
<div className="i-ph:gauge-duotone w-4 h-4 text-bolt-elements-textSecondary" />
<label htmlFor="autoEnergySaver" className="text-sm text-bolt-elements-textSecondary">
Auto Energy Saver
</label>
@ -774,6 +938,7 @@ export default function TaskManagerTab() {
disabled={autoEnergySaver}
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
/>
<div className="i-ph:leaf-duotone w-4 h-4 text-bolt-elements-textSecondary" />
<label
htmlFor="energySaver"
className={classNames('text-sm text-bolt-elements-textSecondary', { 'opacity-50': autoEnergySaver })}
@ -782,24 +947,43 @@ export default function TaskManagerTab() {
{energySaverMode && <span className="ml-2 text-xs text-bolt-elements-textSecondary">Active</span>}
</label>
</div>
<select
value={selectedProfile.name}
onChange={(e) => {
const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
<div className="relative">
<select
value={selectedProfile.name}
onChange={(e) => {
const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
if (profile) {
setSelectedProfile(profile);
toast.success(`Switched to ${profile.name} power profile`);
}
}}
className="px-3 py-1 rounded-md bg-[#F8F8F8] dark:bg-[#141414] border border-[#E5E5E5] dark:border-[#1A1A1A] text-sm"
>
{POWER_PROFILES.map((profile) => (
<option key={profile.name} value={profile.name}>
{profile.name}
</option>
))}
</select>
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) => (
<option
key={profile.name}
value={profile.name}
className="py-2 px-3 bg-bolt-background-secondary dark:bg-[#1E1E1E] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:bg-bolt-background-tertiary dark:hover:bg-bolt-backgroundDark-tertiary cursor-pointer"
>
{profile.name}
</option>
))}
</select>
<div className="absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none">
<div
className={classNames('w-4 h-4 text-bolt-elements-textSecondary', {
'i-ph:lightning-fill text-yellow-500': selectedProfile.name === 'Performance',
'i-ph:scales-fill text-blue-500': selectedProfile.name === 'Balanced',
'i-ph:leaf-fill text-green-500': selectedProfile.name === 'Energy Saver',
})}
/>
</div>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
<div className="i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75" />
</div>
</div>
</div>
</div>
<div className="text-sm text-bolt-elements-textSecondary">{selectedProfile.description}</div>
@ -981,30 +1165,6 @@ export default function TaskManagerTab() {
</div>
)}
{/* Storage Section */}
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textSecondary">Storage</span>
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{formatBytes(metrics.storage.used)} / {formatBytes(metrics.storage.total)}
</span>
</div>
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={classNames('h-full transition-all duration-300', {
'bg-green-500': metrics.storage.used / metrics.storage.total < 0.7,
'bg-yellow-500':
metrics.storage.used / metrics.storage.total >= 0.7 &&
metrics.storage.used / metrics.storage.total < 0.9,
'bg-red-500': metrics.storage.used / metrics.storage.total >= 0.9,
})}
style={{ width: `${(metrics.storage.used / metrics.storage.total) * 100}%` }}
/>
</div>
<div className="text-xs text-bolt-elements-textSecondary mt-2">Free: {formatBytes(metrics.storage.free)}</div>
<div className="text-xs text-bolt-elements-textSecondary">Type: {metrics.storage.type}</div>
</div>
{/* Performance Alerts */}
{alerts.length > 0 && (
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
@ -1071,7 +1231,9 @@ export default function TaskManagerTab() {
</div>
</div>
);
}
};
export default React.memo(TaskManagerTab);
// Helper function to format bytes
const formatBytes = (bytes: number): string => {

View File

@ -35,6 +35,10 @@ interface UpdateInfo {
downloadProgress?: number;
installProgress?: number;
estimatedTimeRemaining?: number;
error?: {
type: string;
message: string;
};
}
interface UpdateSettings {
@ -46,11 +50,8 @@ interface UpdateSettings {
interface UpdateResponse {
success: boolean;
error?: string;
progress?: {
downloaded: number;
total: number;
stage: 'download' | 'install' | 'complete';
};
message?: string;
instructions?: string[];
}
const categorizeChangelog = (messages: string[]) => {
@ -190,63 +191,30 @@ const UpdateTab = () => {
localStorage.setItem('update_settings', JSON.stringify(updateSettings));
}, [updateSettings]);
const handleUpdateProgress = async (response: Response): Promise<void> => {
const reader = response.body?.getReader();
if (!reader) {
return;
}
const contentLength = +(response.headers.get('Content-Length') ?? 0);
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedLength += value.length;
const progress = (receivedLength / contentLength) * 100;
setUpdateInfo((prev) => (prev ? { ...prev, downloadProgress: progress } : prev));
}
};
const checkForUpdates = async () => {
console.log('Starting update check...');
setIsChecking(true);
setError(null);
setLastChecked(new Date());
// Add a minimum delay of 2 seconds to show the spinning animation
const startTime = Date.now();
try {
console.log('Fetching update info...');
const githubToken = localStorage.getItem('github_connection');
const headers: HeadersInit = {};
if (githubToken) {
const { token } = JSON.parse(githubToken);
headers.Authorization = `Bearer ${token}`;
}
const branchToCheck = isLatestBranch ? 'main' : 'stable';
const info = await GITHUB_URLS.commitJson(branchToCheck, headers);
// Ensure we show the spinning animation for at least 2 seconds
const elapsedTime = Date.now() - startTime;
if (elapsedTime < 2000) {
await new Promise((resolve) => setTimeout(resolve, 2000 - elapsedTime));
}
const info = await GITHUB_URLS.commitJson(branchToCheck);
setUpdateInfo(info);
if (info.error) {
setError(info.error.message);
logStore.logWarning('Update Check Failed', {
type: 'update',
message: info.error.message,
});
return;
}
if (info.hasUpdate) {
const existingLogs = Object.values(logStore.logs.get());
const hasUpdateNotification = existingLogs.some(
@ -267,18 +235,23 @@ const UpdateTab = () => {
});
if (updateSettings.autoUpdate && !hasUserRespondedToUpdate) {
setUpdateChangelog(info.changelog || ['No changelog available']);
setUpdateChangelog([
'New version available.',
`Compare changes: https://github.com/stackblitz-labs/bolt.diy/compare/${info.currentVersion}...${info.latestVersion}`,
'',
'Click "Update Now" to start the update process.',
]);
setShowUpdateDialog(true);
}
}
}
} catch (err) {
console.error('Detailed update check error:', err);
setError('Failed to check for updates. Please try again later.');
console.error('Update check failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(`Failed to check for updates: ${errorMessage}`);
setUpdateFailed(true);
} finally {
console.log('Update check completed');
setIsChecking(false);
}
};
@ -292,49 +265,45 @@ const UpdateTab = () => {
const attemptUpdate = async (): Promise<void> => {
try {
const platform = process.platform;
if (platform === 'darwin' || platform === 'linux') {
const response = await fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: isLatestBranch ? 'main' : 'stable',
settings: updateSettings,
}),
});
if (!response.ok) {
throw new Error('Failed to initiate update');
}
await handleUpdateProgress(response);
const result = (await response.json()) as UpdateResponse;
if (result.success) {
logStore.logSuccess('Update downloaded successfully', {
type: 'update',
message: 'Update completed successfully.',
});
toast.success('Update completed successfully!');
setUpdateFailed(false);
return;
}
throw new Error(result.error || 'Update failed');
}
window.open('https://github.com/stackblitz-labs/bolt.diy/releases/latest', '_blank');
logStore.logInfo('Manual update required', {
type: 'update',
message: 'Please download and install the latest version from the GitHub releases page.',
const response = await fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: isLatestBranch ? 'main' : 'stable',
}),
});
return;
if (!response.ok) {
const errorData = (await response.json()) as { error: string };
throw new Error(errorData.error || 'Failed to initiate update');
}
const result = (await response.json()) as UpdateResponse;
if (result.success) {
logStore.logSuccess('Update instructions ready', {
type: 'update',
message: result.message || 'Update instructions ready',
});
// Show manual update instructions
setShowManualInstructions(true);
setUpdateChangelog(
result.instructions || [
'Failed to get update instructions. Please update manually:',
'1. git pull origin main',
'2. pnpm install',
'3. pnpm build',
'4. Restart the application',
],
);
return;
}
throw new Error(result.error || 'Update failed');
} catch (err) {
currentRetry++;
@ -349,13 +318,11 @@ const UpdateTab = () => {
return;
}
setError('Failed to initiate update. Please try again or update manually.');
setError('Failed to get update instructions. Please update manually.');
console.error('Update failed:', err);
logStore.logSystem('Update failed: ' + errorMessage);
toast.error('Update failed: ' + errorMessage);
setUpdateFailed(true);
return;
}
};
@ -518,7 +485,19 @@ const UpdateTab = () => {
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400">
<div className="flex items-center gap-2">
<div className="i-ph:warning-circle" />
{error}
<div className="flex flex-col">
<span className="font-medium">{error}</span>
{error.includes('rate limit') && (
<span className="text-sm mt-1">
Try adding a GitHub token in the connections tab to increase the rate limit.
</span>
)}
{error.includes('authentication') && (
<span className="text-sm mt-1">
Please check your GitHub token configuration in the connections tab.
</span>
)}
</div>
</div>
</div>
)}
@ -803,7 +782,7 @@ const UpdateTab = () => {
</DialogDescription>
<div className="mt-3">
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Changelog:</h3>
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Update Information:</h3>
<div
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 max-h-[300px] overflow-y-auto"
style={{
@ -814,7 +793,18 @@ const UpdateTab = () => {
<div className="text-sm text-bolt-elements-textSecondary space-y-1.5">
{updateChangelog.map((log, index) => (
<div key={index} className="break-words leading-relaxed">
{log}
{log.startsWith('Compare changes:') ? (
<a
href={log.split(': ')[1]}
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300"
>
View changes on GitHub
</a>
) : (
log
)}
</div>
))}
</div>

View File

@ -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,
};

View File

@ -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[];
};

View File

@ -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<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]);
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,9 +281,9 @@ export const ChatImpl = memo(
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const _input = messageInput || input;
const messageContent = messageInput || input;
if (!_input) {
if (!messageContent?.trim()) {
return;
}
@ -280,7 +299,7 @@ export const ChatImpl = memo(
if (autoSelectTemplate) {
const { template, title } = await selectStarterTemplate({
message: _input,
message: messageContent,
model,
provider,
});
@ -302,7 +321,7 @@ export const ChatImpl = memo(
{
id: `${new Date().getTime()}`,
role: 'user',
content: _input,
content: messageContent,
},
{
id: `${new Date().getTime()}`,
@ -332,7 +351,7 @@ 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',
@ -356,31 +375,20 @@ export const ChatImpl = memo(
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({
@ -388,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([]);

View File

@ -6,7 +6,7 @@ import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { RepositorySelectionDialog } from '~/components/settings/connections/components/RepositorySelectionDialog';
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';

View File

@ -1,607 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { Switch } from '@radix-ui/react-switch';
import * as RadixDialog from '@radix-ui/react-dialog';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { classNames } from '~/utils/classNames';
import { TabManagement } from './developer/TabManagement';
import { TabTile } from './shared/TabTile';
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
import { useFeatures } from '~/lib/hooks/useFeatures';
import { useNotifications } from '~/lib/hooks/useNotifications';
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
import { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings';
import type { TabType, TabVisibilityConfig } from './settings.types';
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './settings.types';
import { resetTabConfiguration } from '~/lib/stores/settings';
import { DialogTitle } from '~/components/ui/Dialog';
import { useDrag, useDrop } from 'react-dnd';
// Import all tab components
import ProfileTab from './profile/ProfileTab';
import SettingsTab from './settings/SettingsTab';
import NotificationsTab from './notifications/NotificationsTab';
import FeaturesTab from './features/FeaturesTab';
import DataTab from './data/DataTab';
import DebugTab from './debug/DebugTab';
import { EventLogsTab } from './event-logs/EventLogsTab';
import UpdateTab from './update/UpdateTab';
import ConnectionsTab from './connections/ConnectionsTab';
import CloudProvidersTab from './providers/CloudProvidersTab';
import ServiceStatusTab from './providers/ServiceStatusTab';
import LocalProvidersTab from './providers/LocalProvidersTab';
import TaskManagerTab from './task-manager/TaskManagerTab';
interface ControlPanelProps {
open: boolean;
onClose: () => void;
}
interface TabWithDevType extends TabVisibilityConfig {
isExtraDevTab?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
};
// Add DraggableTabTile component before the ControlPanel component
const DraggableTabTile = ({
tab,
index,
moveTab,
...props
}: {
tab: TabWithDevType;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onClick: () => void;
isActive: boolean;
hasUpdate: boolean;
statusMessage: string;
description: string;
isLoading?: boolean;
}) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: { index, id: tab.id },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: 'tab',
hover: (item: { index: number; id: string }, monitor) => {
if (!monitor.isOver({ shallow: true })) {
return;
}
if (item.id === tab.id) {
return;
}
if (item.index === index) {
return;
}
// Only move when hovering over the middle section
const hoverBoundingRect = monitor.getSourceClientOffset();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) {
return;
}
const hoverMiddleX = hoverBoundingRect.x + 150; // Half of typical card width
const hoverClientX = clientOffset.x;
// Only perform the move when the mouse has crossed half of the items width
if (item.index < index && hoverClientX < hoverMiddleX) {
return;
}
if (item.index > index && hoverClientX > hoverMiddleX) {
return;
}
moveTab(item.index, index);
item.index = index;
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
const dropIndicatorClasses = classNames('rounded-xl border-2 border-transparent transition-all duration-200', {
'ring-2 ring-purple-500 ring-opacity-50 bg-purple-50 dark:bg-purple-900/20': isOver,
'hover:ring-2 hover:ring-purple-500/30': canDrop && !isOver,
});
return (
<motion.div
ref={(node) => drag(drop(node))}
style={{
opacity: isDragging ? 0.5 : 1,
cursor: 'move',
position: 'relative',
zIndex: isDragging ? 100 : isOver ? 50 : 1,
}}
animate={{
scale: isDragging ? 1.02 : isOver ? 1.05 : 1,
boxShadow: isDragging
? '0 8px 24px rgba(0, 0, 0, 0.15)'
: isOver
? '0 4px 12px rgba(147, 51, 234, 0.3)'
: '0 0 0 rgba(0, 0, 0, 0)',
borderColor: isOver ? 'rgb(147, 51, 234)' : isDragging ? 'rgba(147, 51, 234, 0.5)' : 'transparent',
y: isOver ? -2 : 0,
}}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
mass: 0.8,
}}
className={dropIndicatorClasses}
>
<TabTile {...props} tab={tab} />
{isOver && (
<motion.div
className="absolute inset-0 rounded-xl pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/10 to-purple-500/20 rounded-xl" />
<div className="absolute inset-0 border-2 border-purple-500/50 rounded-xl" />
</motion.div>
)}
</motion.div>
);
};
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
// State
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
const [profile, setProfile] = useState({ avatar: null, notifications: true });
// Store values
const tabConfiguration = useStore(tabConfigurationStore);
const developerMode = useStore(developerModeStore);
// Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Initialize profile from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const saved = localStorage.getItem('bolt_user_profile');
if (saved) {
try {
const parsedProfile = JSON.parse(saved);
setProfile(parsedProfile);
} catch (error) {
console.warn('Failed to parse profile from localStorage:', error);
}
}
}, []);
// Add visibleTabs logic using useMemo
const visibleTabs = useMemo(() => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, resetting to defaults');
resetTabConfiguration();
return [];
}
// In developer mode, show ALL tabs without restrictions
if (developerMode) {
// 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),
]);
// 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,
visible: true,
window: 'developer' as const,
order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
};
});
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' && !profile.notifications) {
return false;
}
// 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);
}, [tabConfiguration, profile.notifications, developerMode]);
// Add moveTab handler
const moveTab = (dragIndex: number, hoverIndex: number) => {
const newTabs = [...visibleTabs];
const dragTab = newTabs[dragIndex];
newTabs.splice(dragIndex, 1);
newTabs.splice(hoverIndex, 0, dragTab);
// Update the order of the tabs
const updatedTabs = newTabs.map((tab, index) => ({
...tab,
order: index,
window: 'developer' as const,
visible: true,
}));
// Update the tab configuration store directly
if (developerMode) {
// In developer mode, update developerTabs while preserving configuration
tabConfigurationStore.set({
...tabConfiguration,
developerTabs: updatedTabs,
});
} else {
// In user mode, update userTabs
tabConfigurationStore.set({
...tabConfiguration,
userTabs: updatedTabs.map((tab) => ({ ...tab, window: 'user' as const })),
});
}
};
// Handlers
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
const handleDeveloperModeChange = (checked: boolean) => {
console.log('Developer mode changed:', checked);
setDeveloperMode(checked);
};
// Add effect to log developer mode changes
useEffect(() => {
console.log('Current developer mode:', developerMode);
}, [developerMode]);
const getTabComponent = () => {
switch (activeTab) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
case 'task-manager':
return <TaskManagerTab />;
case 'service-status':
return <ServiceStatusTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'update':
return hasUpdate;
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'connection':
return hasConnectionIssues;
case 'debug':
return hasActiveWarnings;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'update':
return `New update available (v${currentVersion})`;
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'connection':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
case 'debug': {
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
const errors = activeIssues.filter((i) => i.type === 'error').length;
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
}
default:
return '';
}
};
const handleTabClick = (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
// Acknowledge notifications based on tab
switch (tabId) {
case 'update':
acknowledgeUpdate();
break;
case 'features':
acknowledgeAllFeatures();
break;
case 'notifications':
markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
acknowledgeAllIssues();
break;
}
// Clear loading state after a delay
setTimeout(() => setLoadingTab(null), 500);
};
return (
<DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
className="relative z-[101]"
>
<motion.div
className={classNames(
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{activeTab || showTabManagement ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
) : (
<motion.div
className="i-ph:lightning-fill w-5 h-5 text-purple-500"
initial={{ rotate: -10 }}
animate={{ rotate: 10 }}
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2,
ease: 'easeInOut',
}}
/>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center space-x-4">
{/* Only show Manage Tabs button in developer mode */}
{!activeTab && !showTabManagement && developerMode && (
<motion.button
onClick={() => setShowTabManagement(true)}
className="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:sliders-horizontal w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
<span className="text-sm text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors">
Manage Tabs
</span>
</motion.button>
)}
<div className="flex items-center gap-2">
<Switch
id="developer-mode"
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full',
'bg-gray-200 dark:bg-gray-700',
'data-[state=checked]:bg-purple-500',
'transition-colors duration-200',
)}
>
<span className="sr-only">Toggle developer mode</span>
<span
className={classNames(
'inline-block h-4 w-4 transform rounded-full bg-white',
'transition duration-200',
'translate-x-1 data-[state=checked]:translate-x-6',
)}
/>
</Switch>
<label
htmlFor="developer-mode"
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer"
>
{developerMode ? 'Developer Mode' : 'User Mode'}
</label>
</div>
<button
onClick={onClose}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6"
>
{showTabManagement ? (
<TabManagement />
) : activeTab ? (
getTabComponent()
) : (
<motion.div className="grid grid-cols-4 gap-4">
<AnimatePresence mode="popLayout">
{visibleTabs.map((tab: TabWithDevType, index: number) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.05,
}}
>
<DraggableTabTile
tab={tab}
index={index}
moveTab={moveTab}
onClick={() => handleTabClick(tab.id)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
</DndProvider>
);
};

View File

@ -1,650 +0,0 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useMemo } from 'react';
import { classNames } from '~/utils/classNames';
import { TabManagement } from './TabManagement';
import { TabTile } from '~/components/settings/shared/TabTile';
import { DialogTitle } from '~/components/ui/Dialog';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
import {
tabConfigurationStore,
resetTabConfiguration,
updateTabConfiguration,
developerModeStore,
setDeveloperMode,
} from '~/lib/stores/settings';
import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import DebugTab from '~/components/settings/debug/DebugTab';
import { EventLogsTab } from '~/components/settings/event-logs/EventLogsTab';
import UpdateTab from '~/components/settings/update/UpdateTab';
import DataTab from '~/components/settings/data/DataTab';
import FeaturesTab from '~/components/settings/features/FeaturesTab';
import NotificationsTab from '~/components/settings/notifications/NotificationsTab';
import SettingsTab from '~/components/settings/settings/SettingsTab';
import ProfileTab from '~/components/settings/profile/ProfileTab';
import ConnectionsTab from '~/components/settings/connections/ConnectionsTab';
import { useUpdateCheck, useFeatures, useNotifications, useConnectionStatus, useDebugStatus } from '~/lib/hooks';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab';
import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab';
import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab';
import ServiceStatusTab from '~/components/settings/providers/ServiceStatusTab';
import { Switch } from '~/components/ui/Switch';
interface DraggableTabTileProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onClick: () => void;
isActive: boolean;
hasUpdate: boolean;
statusMessage: string;
description: string;
isLoading?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Manage application features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers',
'local-providers': 'Configure local AI providers',
connection: 'View and manage connections',
debug: 'Debug application issues',
'event-logs': 'View application event logs',
update: 'Check for updates',
'task-manager': 'Manage running tasks',
'service-status': 'Monitor provider service health and status',
};
const DraggableTabTile = ({
tab,
index,
moveTab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
}: DraggableTabTileProps) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'tab',
hover: (item: { index: number }) => {
if (item.index === index) {
return;
}
moveTab(item.index, index);
item.index = index;
},
});
const dragDropRef = (node: HTMLDivElement | null) => {
if (node) {
drag(drop(node));
}
};
return (
<div ref={dragDropRef} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile
tab={tab}
onClick={onClick}
isActive={isActive}
hasUpdate={hasUpdate}
statusMessage={statusMessage}
description={description}
isLoading={isLoading}
/>
</div>
);
};
interface DeveloperWindowProps {
open: boolean;
onClose: () => void;
}
export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const tabConfiguration = useStore(tabConfigurationStore);
const [showTabManagement, setShowTabManagement] = useState(false);
const developerMode = useStore(developerModeStore);
const [profile, setProfile] = useState(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved ? JSON.parse(saved) : { avatar: null, notifications: true };
});
// Handle developer mode change
const handleDeveloperModeChange = (checked: boolean) => {
setDeveloperMode(checked);
if (!checked) {
onClose();
}
};
// Ensure developer mode is true when window is opened
useEffect(() => {
if (open) {
setDeveloperMode(true);
}
}, [open]);
// Listen for profile changes
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'bolt_user_profile') {
const newProfile = e.newValue ? JSON.parse(e.newValue) : { avatar: null, notifications: true };
setProfile(newProfile);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
// Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Ensure tab configuration is properly initialized
useEffect(() => {
if (!tabConfiguration || !tabConfiguration.userTabs || !tabConfiguration.developerTabs) {
console.warn('Tab configuration is invalid in DeveloperWindow, resetting to defaults');
resetTabConfiguration();
} else {
// Validate tab configuration structure
const isValid =
tabConfiguration.userTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
) &&
tabConfiguration.developerTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
);
if (!isValid) {
console.warn('Tab configuration is malformed in DeveloperWindow, resetting to defaults');
resetTabConfiguration();
}
}
}, [tabConfiguration]);
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
// Only show tabs that are assigned to the developer window AND are visible
const visibleDeveloperTabs = useMemo(() => {
if (!tabConfiguration?.developerTabs || !Array.isArray(tabConfiguration.developerTabs)) {
console.warn('Invalid tab configuration, using empty array');
return [];
}
return tabConfiguration.developerTabs
.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' && !profile.notifications) {
return false;
}
// Ensure the tab has the required properties
if (typeof tab.visible !== 'boolean' || typeof tab.window !== 'string' || typeof tab.order !== 'number') {
console.warn('Tab missing required properties:', tab);
return false;
}
// Only show tabs that are explicitly visible and assigned to the developer window
const isVisible = tab.visible && tab.window === 'developer';
return isVisible;
})
.sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => {
const orderA = typeof a.order === 'number' ? a.order : 0;
const orderB = typeof b.order === 'number' ? b.order : 0;
return orderA - orderB;
});
}, [tabConfiguration, profile.notifications]);
const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleDeveloperTabs[dragIndex];
const targetTab = visibleDeveloperTabs[hoverIndex];
console.log('Moving developer tab:', { draggedTab, targetTab });
// Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order };
// Update both tabs in the store
updateTabConfiguration(updatedDraggedTab);
updateTabConfiguration(updatedTargetTab);
};
const handleTabClick = (tabId: TabType) => {
// Don't allow clicking notifications tab if disabled
if (tabId === 'notifications' && !profile.notifications) {
return;
}
setLoadingTab(tabId);
setActiveTab(tabId);
// Acknowledge the status based on tab type
switch (tabId) {
case 'update':
acknowledgeUpdate();
break;
case 'features':
acknowledgeAllFeatures();
break;
case 'notifications':
markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
acknowledgeAllIssues();
break;
}
// Clear loading state after a short delay
setTimeout(() => {
setLoadingTab(null);
}, 500);
};
const getTabComponent = () => {
switch (activeTab) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
case 'task-manager':
return <TaskManagerTab />;
case 'service-status':
return <ServiceStatusTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'update':
return hasUpdate;
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'connection':
return hasConnectionIssues;
case 'debug':
return hasActiveWarnings;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'update':
return `New update available (v${currentVersion})`;
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'connection':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
case 'debug': {
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
const errors = activeIssues.filter((i) => i.type === 'error').length;
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
}
default:
return '';
}
};
// Trap focus when window is open
useEffect(() => {
if (open) {
// Prevent background scrolling
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [open]);
return (
<>
<DropdownMenu.Root>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-[200] animate-in fade-in-0 zoom-in-95"
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('profile')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:user-circle w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('settings')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:gear w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Settings</span>
</DropdownMenu.Item>
{profile.notifications && (
<>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('notifications')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:bell w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">
Notifications
{hasUnreadNotifications && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-purple-500 text-white rounded-full">
{unreadNotifications.length}
</span>
)}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
</>
)}
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('task-manager')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:activity w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Task Manager</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={onClose}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:sign-out w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Close</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
<DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay className="fixed inset-0">
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
className="relative z-[101]"
>
<motion.div
className={classNames(
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
)}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{activeTab || showTabManagement ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
) : (
<motion.div
className="i-ph:lightning-fill w-5 h-5 text-purple-500"
initial={{ rotate: -10 }}
animate={{ rotate: 10 }}
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2,
ease: 'easeInOut',
}}
/>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{showTabManagement ? 'Tab Management' : activeTab ? 'Developer Tools' : 'Developer Settings'}
</DialogTitle>
</div>
<div className="flex items-center space-x-4">
{!activeTab && !showTabManagement && (
<motion.button
onClick={() => setShowTabManagement(true)}
className="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:sliders-horizontal w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
<span className="text-sm text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors">
Manage Tabs
</span>
</motion.button>
)}
<div className="flex items-center gap-2">
<Switch
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className="data-[state=checked]:bg-purple-500"
aria-label="Toggle developer mode"
/>
<label className="text-sm text-gray-500 dark:text-gray-400">Switch to User Mode</label>
</div>
<div className="relative">
<DropdownMenu.Trigger asChild>
<button className="flex items-center justify-center w-8 h-8 rounded-full overflow-hidden hover:ring-2 ring-gray-300 dark:ring-gray-600 transition-all">
{profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
)}
</button>
</DropdownMenu.Trigger>
</div>
<button
onClick={onClose}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6"
>
{showTabManagement ? (
<TabManagement />
) : activeTab ? (
getTabComponent()
) : (
<motion.div
className="grid grid-cols-4 gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<AnimatePresence mode="popLayout">
{visibleDeveloperTabs.map((tab: TabVisibilityConfig, index: number) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.05,
}}
>
<DraggableTabTile
tab={tab}
index={index}
moveTab={moveTab}
onClick={() => handleTabClick(tab.id)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
</DndProvider>
</DropdownMenu.Root>
</>
);
};

View File

@ -1,234 +0,0 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useMemo } from 'react';
import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { classNames } from '~/utils/classNames';
import { tabConfigurationStore, resetTabConfiguration } from '~/lib/stores/settings';
import {
TAB_LABELS,
DEFAULT_TAB_CONFIG,
type TabType,
type TabVisibilityConfig,
} from '~/components/settings/settings.types';
import { toast } from 'react-toastify';
// Define icons for each tab type
const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:sparkle-fill',
data: 'i-ph:database-fill',
'cloud-providers': 'i-ph:cloud-fill',
'local-providers': 'i-ph:desktop-fill',
connection: 'i-ph:plug-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
'task-manager': 'i-ph:activity-fill',
'service-status': 'i-ph:heartbeat-fill',
};
interface DraggableTabProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onVisibilityChange: (enabled: boolean) => void;
}
const DraggableTab = ({ tab, index, moveTab, onVisibilityChange }: DraggableTabProps) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab-management',
item: { index, id: tab.id },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver }, drop] = useDrop({
accept: 'tab-management',
hover: (item: { index: number; id: string }, monitor) => {
if (!monitor.isOver({ shallow: true })) {
return;
}
if (item.id === tab.id) {
return;
}
if (item.index === index) {
return;
}
moveTab(item.index, index);
item.index = index;
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
}),
});
return (
<motion.div
ref={(node) => drag(drop(node))}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
style={{
opacity: isDragging ? 0.5 : 1,
cursor: 'move',
}}
className={classNames(
'group relative flex items-center justify-between rounded-lg border px-4 py-3 transition-all',
isOver
? 'border-purple-500 bg-purple-50/50 dark:border-purple-500/50 dark:bg-purple-500/10'
: 'border-gray-200 bg-white hover:border-purple-200 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-purple-500/30',
)}
>
<div className="flex items-center space-x-3">
<div className={classNames(TAB_ICONS[tab.id], 'h-5 w-5 text-purple-500 dark:text-purple-400')} />
<span className="text-sm font-medium text-gray-900 dark:text-white">{TAB_LABELS[tab.id]}</span>
</div>
<div className="flex items-center space-x-4">
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={tab.visible}
onChange={(e) => onVisibilityChange(e.target.checked)}
className="peer sr-only"
/>
<div
className={classNames(
'h-6 w-11 rounded-full bg-gray-200 transition-colors dark:bg-gray-700',
'after:absolute after:left-[2px] after:top-[2px]',
'after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm',
'after:transition-all after:content-[""]',
'peer-checked:bg-purple-500 peer-checked:after:translate-x-full',
'peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-500/20',
)}
/>
</label>
</div>
</motion.div>
);
};
export const TabManagement = () => {
const config = useStore(tabConfigurationStore);
const [searchQuery, setSearchQuery] = useState('');
// Get ALL possible tabs for developer mode
const allTabs = useMemo(() => {
const uniqueTabs = new Set([
...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
...(config.userTabs || []).map((tab) => tab.id),
...(config.developerTabs || []).map((tab) => tab.id),
'event-logs', // Ensure these are always included
'task-manager',
]);
return Array.from(uniqueTabs).map((tabId) => {
const existingTab =
config.developerTabs?.find((t) => t.id === tabId) ||
config.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),
};
});
}, [config]);
const handleVisibilityChange = (tabId: TabType, enabled: boolean) => {
const updatedDevTabs = allTabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, visible: enabled };
}
return tab;
});
tabConfigurationStore.set({
...config,
developerTabs: updatedDevTabs,
});
toast.success(`${TAB_LABELS[tabId]} ${enabled ? 'enabled' : 'disabled'}`);
};
const moveTab = (dragIndex: number, hoverIndex: number) => {
const newTabs = [...allTabs];
const dragTab = newTabs[dragIndex];
newTabs.splice(dragIndex, 1);
newTabs.splice(hoverIndex, 0, dragTab);
const updatedTabs = newTabs.map((tab, index) => ({
...tab,
order: index,
}));
tabConfigurationStore.set({
...config,
developerTabs: updatedTabs,
});
};
const handleResetToDefaults = () => {
resetTabConfiguration();
toast.success('Tab settings reset to defaults');
};
const filteredTabs = allTabs
.filter((tab) => tab && TAB_LABELS[tab.id]?.toLowerCase().includes((searchQuery || '').toLowerCase()))
.sort((a, b) => a.order - b.order);
return (
<DndProvider backend={HTML5Backend}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="relative flex-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="i-ph:magnifying-glass h-5 w-5 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tabs..."
className="block w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder:text-gray-500 focus:border-purple-500 focus:outline-none focus:ring-4 focus:ring-purple-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-purple-400"
/>
</div>
<button
onClick={handleResetToDefaults}
className="ml-4 inline-flex items-center gap-1.5 rounded-lg bg-purple-50 px-4 py-2 text-sm font-medium text-purple-600 transition-colors hover:bg-purple-100 focus:outline-none focus:ring-4 focus:ring-purple-500/20 dark:bg-purple-500/10 dark:text-purple-400 dark:hover:bg-purple-500/20"
>
<span className="i-ph:arrow-counter-clockwise-fill h-4 w-4" />
Reset to Defaults
</button>
</div>
<div className="rounded-xl border border-purple-100 bg-purple-50/50 p-6 dark:border-purple-500/10 dark:bg-purple-500/5">
<AnimatePresence mode="popLayout">
<div className="space-y-2">
{filteredTabs.map((tab, index) => (
<DraggableTab
key={tab.id}
tab={tab}
index={index}
moveTab={moveTab}
onVisibilityChange={(enabled) => handleVisibilityChange(tab.id, enabled)}
/>
))}
</div>
</AnimatePresence>
</div>
</div>
</DndProvider>
);
};

View File

@ -1,437 +0,0 @@
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';
interface SelectOption {
value: string;
label: string;
icon?: string;
color?: string;
}
const logLevelOptions: SelectOption[] = [
{
value: 'all',
label: 'All Levels',
icon: 'i-ph:funnel',
color: '#9333ea',
},
{
value: 'info',
label: 'Info',
icon: 'i-ph:info',
color: '#3b82f6',
},
{
value: 'warning',
label: 'Warning',
icon: 'i-ph:warning',
color: '#f59e0b',
},
{
value: 'error',
label: 'Error',
icon: 'i-ph:x-circle',
color: '#ef4444',
},
{
value: 'debug',
label: 'Debug',
icon: 'i-ph:bug',
color: '#6b7280',
},
];
const logCategoryOptions: SelectOption[] = [
{
value: 'all',
label: 'All Categories',
icon: 'i-ph:squares-four',
color: '#9333ea',
},
{
value: 'api',
label: 'API',
icon: 'i-ph:cloud',
color: '#3b82f6',
},
{
value: 'auth',
label: 'Auth',
icon: 'i-ph:key',
color: '#f59e0b',
},
{
value: 'database',
label: 'Database',
icon: 'i-ph:database',
color: '#10b981',
},
{
value: 'network',
label: 'Network',
icon: 'i-ph:wifi-high',
color: '#6366f1',
},
{
value: 'performance',
label: 'Performance',
icon: 'i-ph:chart-line-up',
color: '#8b5cf6',
},
];
interface LogEntryItemProps {
log: LogEntry;
isExpanded: boolean;
use24Hour: boolean;
showTimestamp: boolean;
}
const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
const [localExpanded, setLocalExpanded] = useState(forceExpanded);
// Update local expanded state when forceExpanded changes
useEffect(() => {
setLocalExpanded(forceExpanded);
}, [forceExpanded]);
const timestamp = useMemo(() => {
const date = new Date(log.timestamp);
if (use24Hour) {
return date.toLocaleTimeString('en-US', { hour12: false });
}
return date.toLocaleTimeString('en-US', { hour12: true });
}, [log.timestamp, use24Hour]);
const levelColor = useMemo(() => {
switch (log.level) {
case 'error':
return 'text-red-500 bg-red-50 dark:bg-red-500/10';
case 'warning':
return 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10';
case 'debug':
return 'text-gray-500 bg-gray-50 dark:bg-gray-500/10';
default:
return 'text-blue-500 bg-blue-50 dark:bg-blue-500/10';
}
}, [log.level]);
return (
<div className="p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<div className="flex items-start gap-3">
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', levelColor)}>{log.level}</div>
{showTimestamp && <div className="text-sm text-bolt-elements-textTertiary">{timestamp}</div>}
<div className="flex-grow">
<div className="text-sm text-bolt-elements-textPrimary">{log.message}</div>
{log.details && (
<button
onClick={() => setLocalExpanded(!localExpanded)}
className="mt-1 text-xs text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
>
{localExpanded ? 'Hide' : 'Show'} Details
</button>
)}
</div>
{log.category && (
<div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-bolt-elements-textSecondary">
{log.category}
</div>
)}
</div>
{localExpanded && log.details && (
<div className="mt-2 p-2 rounded bg-gray-50 dark:bg-gray-800/50">
<pre className="text-xs text-bolt-elements-textSecondary whitespace-pre-wrap">
{JSON.stringify(log.details, null, 2)}
</pre>
</div>
)}
</div>
);
};
export function EventLogsTab() {
const logs = useStore(logStore.logs);
const [selectedLevel, setSelectedLevel] = useState('all');
const [selectedCategory, setSelectedCategory] = useState('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 [showCategoryFilter, setShowCategoryFilter] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const logsContainerRef = useRef<HTMLDivElement>(null);
const levelFilterRef = useRef<HTMLDivElement>(null);
const categoryFilterRef = useRef<HTMLDivElement>(null);
const filteredLogs = useMemo(() => {
return logStore.getFilteredLogs(
selectedLevel === 'all' ? undefined : (selectedLevel as LogEntry['level']),
selectedCategory === 'all' ? undefined : (selectedCategory as LogEntry['category']),
searchQuery,
);
}, [logs, selectedLevel, selectedCategory, searchQuery]);
const handleExportLogs = useCallback(() => {
const exportData = {
timestamp: new Date().toISOString(),
logs: filteredLogs,
filters: {
level: selectedLevel,
category: selectedCategory,
searchQuery,
},
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bolt-logs-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [filteredLogs, selectedLevel, selectedCategory, searchQuery]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await logStore.refreshLogs();
setTimeout(() => setIsRefreshing(false), 500); // Keep animation visible for at least 500ms
}, []);
// Close filters when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
setShowLevelFilter(false);
}
if (categoryFilterRef.current && !categoryFilterRef.current.contains(event.target as Node)) {
setShowCategoryFilter(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
const selectedCategoryOption = logCategoryOptions.find((opt) => opt.value === selectedCategory);
return (
<div
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="i-ph:list text-xl text-purple-500" />
<div>
<h2 className="text-lg font-semibold">Event Logs</h2>
<p className="text-sm text-bolt-elements-textSecondary">Track system events and debug information</p>
</div>
</div>
<div className="flex items-center gap-4">
<button
onClick={handleRefresh}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
>
<div className="i-ph:arrows-clockwise text-lg" />
{isRefreshing ? 'Refreshing...' : 'Refresh Logs'}
</button>
</div>
</div>
{/* Top Controls */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="flex-grow relative">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-1.5 pl-9 rounded-lg text-sm bg-white/50 dark:bg-gray-800/30 border border-gray-200/50 dark:border-gray-700/50"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
<div className="i-ph:magnifying-glass text-base" />
</div>
</div>
{/* Right Controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch checked={showTimestamps} onCheckedChange={setShowTimestamps} />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Timestamps</span>
</div>
<div className="flex items-center gap-2">
<Switch checked={use24Hour} onCheckedChange={setUse24Hour} />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">24h Time</span>
</div>
<div className="flex items-center gap-2">
<Switch checked={autoExpand} onCheckedChange={setAutoExpand} />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto Expand</span>
</div>
<motion.button
onClick={handleExportLogs}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:download text-base" />
Export Logs
</motion.button>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-4 -mt-2">
{/* Level Filter */}
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
<DropdownMenu.Trigger asChild>
<button
className={classNames(
'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',
)}
>
<div
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
style={{ color: selectedLevelOption?.color }}
/>
<span>{selectedLevelOption?.label || 'All Levels'}</span>
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
sideOffset={5}
align="start"
side="bottom"
>
{logLevelOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onClick={() => setSelectedLevel(option.value)}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
style={{ color: option.color }}
/>
</div>
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
{/* Category Filter */}
<DropdownMenu.Root open={showCategoryFilter} onOpenChange={setShowCategoryFilter}>
<DropdownMenu.Trigger asChild>
<button
className={classNames(
'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',
)}
>
<div
className={classNames('text-lg', selectedCategoryOption?.icon || 'i-ph:squares-four')}
style={{ color: selectedCategoryOption?.color }}
/>
<span>{selectedCategoryOption?.label || 'All Categories'}</span>
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
sideOffset={5}
align="start"
side="bottom"
>
{logCategoryOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onClick={() => setSelectedCategory(option.value)}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
style={{ color: option.color }}
/>
</div>
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/* Logs Container */}
<div
ref={logsContainerRef}
className="flex-grow overflow-y-auto rounded-lg bg-white/50 dark:bg-gray-800/30 border border-gray-200/50 dark:border-gray-700/50"
>
<div className="divide-y divide-gray-200/50 dark:divide-gray-700/50">
{filteredLogs.map((log) => (
<LogEntryItem
key={log.id}
log={log}
isExpanded={autoExpand}
use24Hour={use24Hour}
showTimestamp={showTimestamps}
/>
))}
</div>
</div>
</div>
);
}

View File

@ -1,279 +0,0 @@
import React, { useState, useRef } from 'react';
import { AnimatePresence } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import type { UserProfile } from '~/components/settings/settings.types';
import { motion } from 'framer-motion';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const MIN_PASSWORD_LENGTH = 8;
export default function ProfileTab() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [profile, setProfile] = useState<UserProfile>(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved
? JSON.parse(saved)
: {
name: '',
email: '',
password: '',
bio: '',
};
});
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
toast.error('Please upload a valid image file (JPEG, PNG, or GIF)');
return;
}
if (file.size > MAX_FILE_SIZE) {
toast.error('File size must be less than 5MB');
return;
}
setIsLoading(true);
try {
const reader = new FileReader();
reader.onloadend = () => {
setProfile((prev) => ({ ...prev, avatar: reader.result as string }));
setIsLoading(false);
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error uploading avatar:', error);
toast.error('Failed to upload avatar');
setIsLoading(false);
}
};
const handleSave = async () => {
if (!profile.name.trim()) {
toast.error('Name is required');
return;
}
if (!profile.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email)) {
toast.error('Please enter a valid email address');
return;
}
if (profile.password && profile.password.length < MIN_PASSWORD_LENGTH) {
toast.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters long`);
return;
}
setIsLoading(true);
try {
// Get existing profile data to preserve settings
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
// Merge with new profile data
const updatedProfile = {
...existingProfile,
name: profile.name,
email: profile.email,
password: profile.password,
bio: profile.bio,
avatar: profile.avatar,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
// Dispatch a storage event to notify other components
window.dispatchEvent(
new StorageEvent('storage', {
key: 'bolt_user_profile',
newValue: JSON.stringify(updatedProfile),
}),
);
toast.success('Profile settings saved successfully');
} catch (error) {
console.error('Error saving profile:', error);
toast.error('Failed to save profile settings');
} finally {
setIsLoading(false);
}
};
return (
<div
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
>
{/* Profile Information */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<div className="i-ph:user-circle-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Personal Information</span>
</div>
<div className="flex items-start gap-4 p-4">
{/* Avatar */}
<div className="relative group">
<div className="w-12 h-12 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] flex items-center justify-center overflow-hidden">
<AnimatePresence mode="wait">
{isLoading ? (
<div className="i-ph:spinner-gap-bold animate-spin text-purple-500" />
) : profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="i-ph:user-circle-fill text-bolt-elements-textSecondary" />
)}
</AnimatePresence>
</div>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg"
>
<div className="i-ph:camera-fill text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_FILE_TYPES.join(',')}
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{/* Profile Fields */}
<div className="flex-1 space-y-3">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:user-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="text"
value={profile.name}
onChange={(e) => setProfile((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Enter your name"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:envelope-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
/>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={profile.password}
onChange={(e) => setProfile((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Enter new password"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={classNames(
'absolute right-3 top-1/2 -translate-y-1/2',
'flex items-center justify-center',
'w-6 h-6 rounded-md',
'text-bolt-elements-textSecondary',
'hover:text-bolt-elements-item-contentActive',
'hover:bg-bolt-elements-item-backgroundActive',
'transition-colors',
)}
>
<div className={classNames(showPassword ? 'i-ph:eye-slash-fill' : 'i-ph:eye-fill', 'w-4 h-4')} />
</button>
</div>
<div className="relative">
<textarea
value={profile.bio}
onChange={(e) => setProfile((prev) => ({ ...prev, bio: e.target.value }))}
placeholder="Tell us about yourself"
rows={3}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
'resize-none',
)}
/>
</div>
</div>
</div>
</motion.div>
{/* Save Button */}
<div className="flex justify-end mt-6">
<button
onClick={handleSave}
disabled={isLoading}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin" />
Saving...
</>
) : (
<>
<div className="i-ph:check-circle-fill" />
Save Changes
</>
)}
</button>
</div>
</div>
);
}

View File

@ -1,914 +0,0 @@
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 } 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';
// 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<ProviderName, IconType> = {
Ollama: BsRobot,
LMStudio: BsRobot,
OpenAILike: TbBrandOpenai,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
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 OllamaServiceStatus {
isRunning: boolean;
lastChecked: Date;
error?: 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'
);
};
interface ManualInstallState {
isOpen: boolean;
modelString: string;
}
export function LocalProvidersTab() {
const { success, error } = useToast();
const { providers, updateProviderSettings } = useSettings();
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [serviceStatus, setServiceStatus] = useState<OllamaServiceStatus>({
isRunning: false,
lastChecked: new Date(),
});
const [isInstallingModel, setIsInstallingModel] = useState<string | null>(null);
const [installProgress, setInstallProgress] = useState<{
model: string;
progress: number;
status: string;
} | null>(null);
const [manualInstall, setManualInstall] = useState<ManualInstallState>({
isOpen: false,
modelString: '',
});
// 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;
// Get environment URL safely
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
console.log(`Checking env URL for ${key}:`, {
envKey,
envUrl,
currentBaseUrl: provider.settings.baseUrl,
});
// If there's an environment URL and no base URL set, update it
if (envUrl && !provider.settings.baseUrl) {
console.log(`Setting base URL for ${key} from env:`, envUrl);
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]);
// Helper function to safely get environment URL
const getEnvUrl = (provider: IProviderConfig): string | undefined => {
const envKey = providerBaseUrlEnvKeys[provider.name]?.baseUrlKey;
return envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
};
// 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<{ success: boolean; newDigest?: string }> => {
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 { success: true, newDigest: updatedModel?.digest };
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return { success: false };
}
};
const handleToggleCategory = useCallback(
(enabled: boolean) => {
setCategoryEnabled(enabled);
filteredProviders.forEach((provider) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
success(enabled ? 'All local providers enabled' : 'All local providers disabled');
},
[filteredProviders, updateProviderSettings, success],
);
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
success(`${provider.name} enabled`);
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
success(`${provider.name} disabled`);
}
};
const handleUpdateBaseUrl = (provider: IProviderConfig, baseUrl: string) => {
let newBaseUrl: string | undefined = baseUrl;
if (newBaseUrl && newBaseUrl.trim().length === 0) {
newBaseUrl = undefined;
}
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
});
success(`${provider.name} base URL updated`);
setEditingProvider(null);
};
const handleUpdateOllamaModel = async (modelName: string) => {
setOllamaModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const { success: updateSuccess, newDigest } = await updateOllamaModel(modelName);
setOllamaModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
status: updateSuccess ? 'updated' : 'error',
error: updateSuccess ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
if (updateSuccess) {
success(`Updated ${modelName}`);
} else {
error(`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));
success(`Deleted ${modelName}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error deleting ${modelName}:`, errorMessage);
error(`Failed to delete ${modelName}`);
}
};
// Health check function
const checkOllamaHealth = async () => {
try {
// Use the root endpoint instead of /api/health
const response = await fetch(OLLAMA_API_URL);
const text = await response.text();
const isRunning = text.includes('Ollama is running');
setServiceStatus({
isRunning,
lastChecked: new Date(),
});
if (isRunning) {
// If Ollama is running, fetch models
fetchOllamaModels();
}
return isRunning;
} catch (error) {
console.error('Health check error:', error);
setServiceStatus({
isRunning: false,
lastChecked: new Date(),
error: error instanceof Error ? error.message : 'Failed to connect to Ollama service',
});
return false;
}
};
// Update manual installation function
const handleManualInstall = async (modelString: string) => {
try {
setIsInstallingModel(modelString);
setInstallProgress({ model: modelString, progress: 0, status: 'Starting download...' });
setManualInstall((prev) => ({ ...prev, isOpen: false }));
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelString }),
});
if (!response.ok) {
throw new Error(`Failed to install ${modelString}`);
}
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;
}
setInstallProgress({
model: modelString,
progress: rawData.completed && rawData.total ? (rawData.completed / rawData.total) * 100 : 0,
status: rawData.status,
});
}
}
success(`Successfully installed ${modelString}`);
await fetchOllamaModels();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error installing ${modelString}:`, errorMessage);
error(`Failed to install ${modelString}`);
} finally {
setIsInstallingModel(null);
setInstallProgress(null);
}
};
// Add health check effect
useEffect(() => {
const checkHealth = async () => {
const isHealthy = await checkOllamaHealth();
if (!isHealthy) {
error('Ollama service is not running. Please start the Ollama service.');
}
};
checkHealth();
const interval = setInterval(checkHealth, 50000);
// Check every 30 seconds
return () => clearInterval(interval);
}, []);
return (
<div
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
>
{/* Service Status Indicator - Move to top */}
<div
className={classNames(
'flex items-center gap-2 p-2 rounded-lg',
serviceStatus.isRunning ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500',
)}
>
<div className={classNames('w-2 h-2 rounded-full', serviceStatus.isRunning ? 'bg-green-500' : 'bg-red-500')} />
<span className="text-sm">
{serviceStatus.isRunning ? 'Ollama service is running' : 'Ollama service is not running'}
</span>
<span className="text-xs text-bolt-elements-textSecondary ml-2">
Last checked: {serviceStatus.lastChecked.toLocaleTimeString()}
</span>
</div>
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-8 h-8 flex items-center justify-center rounded-lg',
'bg-bolt-elements-background-depth-3',
'text-purple-500',
)}
>
<BiChip className="w-5 h-5" />
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Local Providers</h4>
<p className="text-sm text-bolt-elements-textSecondary">
Configure and update local AI models on your machine
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textSecondary">Enable All Local</span>
<Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{filteredProviders.map((provider, index) => (
<motion.div
key={provider.name}
className={classNames(
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'relative overflow-hidden group',
'flex flex-col',
// Make Ollama span 2 rows
provider.name === 'Ollama' ? 'row-span-2' : '',
// Place Ollama in the second column
provider.name === 'Ollama' ? 'col-start-2' : 'col-start-1',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
<div className="absolute top-0 right-0 p-2 flex gap-1">
<motion.span
className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500 font-medium"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Local
</motion.span>
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.span
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Configurable
</motion.span>
)}
</div>
<div className="flex items-start gap-4 p-4">
<motion.div
className={classNames(
'w-10 h-10 flex items-center justify-center rounded-xl',
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
'transition-all duration-200',
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
className: 'w-full h-full',
'aria-label': `${provider.name} logo`,
})}
</div>
</motion.div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-4 mb-2">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
{provider.name}
</h4>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
</p>
</div>
<Switch
checked={provider.settings.enabled}
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
/>
</div>
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center gap-2 mt-4">
{editingProvider === provider.name ? (
<input
type="text"
defaultValue={provider.settings.baseUrl}
placeholder={`Enter ${provider.name} base URL`}
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm',
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateBaseUrl(provider, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingProvider(null);
}
}}
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
autoFocus
/>
) : (
<div
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
onClick={() => setEditingProvider(provider.name)}
>
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:link text-sm" />
<span className="group-hover/url:text-purple-500 transition-colors">
{provider.settings.baseUrl || 'Click to set base URL'}
</span>
</div>
</div>
)}
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
<div className="mt-2 text-xs">
<div className="flex items-center gap-1">
<div
className={
getEnvUrl(provider)
? 'i-ph:check-circle text-green-500'
: 'i-ph:warning-circle text-yellow-500'
}
/>
<span className={getEnvUrl(provider) ? 'text-green-500' : 'text-yellow-500'}>
{getEnvUrl(provider)
? 'Environment URL set in .env.local'
: 'Environment URL not set in .env.local'}
</span>
</div>
</div>
)}
</div>
</motion.div>
)}
</div>
</div>
{provider.name === 'Ollama' && provider.settings.enabled && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:cube-duotone text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</span>
</div>
{isLoadingModels ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
Loading models...
</div>
) : (
<span className="text-sm text-bolt-elements-textSecondary">
{ollamaModels.length} models available
</span>
)}
</div>
<div className="space-y-2">
{ollamaModels.map((model) => (
<div
key={model.name}
className="flex items-center justify-between p-2 rounded-lg bg-bolt-elements-background-depth-3"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
{model.status === 'updating' && (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 text-purple-500" />
)}
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
<span>Version: {model.digest.substring(0, 7)}</span>
{model.status === 'updated' && model.newDigest && (
<>
<div className="i-ph:arrow-right w-3 h-3" />
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
</>
)}
{model.progress && (
<span className="ml-2">
{model.progress.status}{' '}
{model.progress.total > 0 && (
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
)}
</span>
)}
{model.details && (
<span className="ml-2">
({model.details.parameter_size}, {model.details.quantization_level})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<motion.button
onClick={() => handleUpdateOllamaModel(model.name)}
disabled={model.status === 'updating'}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update
</motion.button>
<motion.button
onClick={() => {
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
handleDeleteOllamaModel(model.name);
}
}}
disabled={model.status === 'updating'}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-red-500 text-white',
'hover:bg-red-600',
'dark:bg-red-500 dark:hover:bg-red-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:trash" />
Delete
</motion.button>
</div>
</div>
))}
</div>
</div>
)}
<motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
scale: provider.settings.enabled ? 1 : 0.98,
}}
transition={{ duration: 0.2 }}
/>
</motion.div>
))}
</div>
</motion.div>
{/* Manual Installation Section */}
{serviceStatus.isRunning && (
<div className="mt-8 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Install New Model</h3>
<p className="text-sm text-bolt-elements-textSecondary">
Enter the model name exactly as shown (e.g., deepseek-r1:1.5b)
</p>
</div>
</div>
{/* Model Information Section */}
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-2 space-y-3">
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
<div className="i-ph:info text-purple-500" />
<span className="font-medium">Where to find models?</span>
</div>
<div className="space-y-2 text-sm text-bolt-elements-textSecondary">
<p>
Browse available models at{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline"
>
ollama.com/library
</a>
</p>
<div className="space-y-1">
<p className="font-medium text-bolt-elements-textPrimary">Popular models:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>deepseek-r1:1.5b - DeepSeek's reasoning model</li>
<li>llama3:8b - Meta's Llama 3 (8B parameters)</li>
<li>mistral:7b - Mistral's 7B model</li>
<li>gemma:2b - Google's Gemma model</li>
<li>qwen2:7b - Alibaba's Qwen2 model</li>
</ul>
</div>
<p className="mt-2">
<span className="text-yellow-500">Note:</span> Copy the exact model name including the tag (e.g.,
'deepseek-r1:1.5b') from the library to ensure successful installation.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1">
<input
type="text"
className="w-full px-3 py-2 rounded-md bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary"
placeholder="deepseek-r1:1.5b"
value={manualInstall.modelString}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setManualInstall((prev) => ({ ...prev, modelString: e.target.value }))
}
/>
</div>
<motion.button
onClick={() => handleManualInstall(manualInstall.modelString)}
disabled={!manualInstall.modelString || !!isInstallingModel}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isInstallingModel ? (
<div className="flex items-center justify-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin" />
Installing...
</div>
) : (
<>
<div className="i-ph:download" />
Install Model
</>
)}
</motion.button>
{isInstallingModel && (
<motion.button
onClick={() => {
setIsInstallingModel(null);
setInstallProgress(null);
error('Installation cancelled');
}}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-red-500 text-white',
'hover:bg-red-600',
'dark:bg-red-500 dark:hover:bg-red-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:x" />
Cancel
</motion.button>
)}
</div>
{installProgress && (
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between text-sm text-bolt-elements-textSecondary">
<span>{installProgress.status}</span>
<span>{Math.round(installProgress.progress)}%</span>
</div>
<div className="w-full h-2 bg-bolt-elements-background-depth-3 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 transition-all duration-200"
style={{ width: `${installProgress.progress}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
);
}
export default LocalProvidersTab;

View File

@ -1,330 +0,0 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
interface OllamaModel {
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
error?: string;
newDigest?: string;
progress?: {
current: number;
total: number;
status: string;
};
}
interface OllamaTagResponse {
models: Array<{
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
}>;
}
interface OllamaPullResponse {
status: string;
digest?: string;
total?: number;
completed?: number;
}
export default function OllamaModelUpdater() {
const [models, setModels] = useState<OllamaModel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
useEffect(() => {
fetchModels();
}, []);
const fetchModels = async () => {
try {
setIsLoading(true);
const response = await fetch('http://localhost:11434/api/tags');
const data = (await response.json()) as OllamaTagResponse;
setModels(
data.models.map((model) => ({
name: model.name,
digest: model.digest,
size: model.size,
modified_at: model.modified_at,
details: model.details,
status: 'idle' as const,
})),
);
} catch (error) {
toast.error('Failed to fetch Ollama models');
console.error('Error fetching models:', error);
} finally {
setIsLoading(false);
}
};
const updateModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => {
try {
const response = await fetch('http://localhost:11434/api/pull', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to update ${modelName}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response reader available');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const data = JSON.parse(line) as OllamaPullResponse;
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: data.completed || 0,
total: data.total || 0,
status: data.status,
},
newDigest: data.digest,
}
: m,
),
);
}
}
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'checking' } : m)));
const updatedResponse = await fetch('http://localhost:11434/api/tags');
const data = (await updatedResponse.json()) as OllamaTagResponse;
const updatedModel = data.models.find((m) => m.name === modelName);
return { success: true, newDigest: updatedModel?.digest };
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return { success: false };
}
};
const handleBulkUpdate = async () => {
setIsBulkUpdating(true);
for (const model of models) {
setModels((current) => current.map((m) => (m.name === model.name ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(model.name);
setModels((current) =>
current.map((m) =>
m.name === model.name
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
}
setIsBulkUpdating(false);
toast.success('Bulk update completed');
};
const handleSingleUpdate = async (modelName: string) => {
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(modelName);
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
if (success) {
toast.success(`Updated ${modelName}`);
} else {
toast.error(`Failed to update ${modelName}`);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<div
className={classNames(
'rounded-full border-4 border-t-4 border-b-4 border-purple-500',
'h-16 w-16 animate-spin',
)}
/>
<span className="ml-2 text-bolt-elements-textSecondary">Loading models...</span>
</div>
);
}
return (
<div
className={classNames(
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
'hover:bg-bolt-elements-background-depth-2',
'transition-all duration-200',
)}
>
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-2xl font-bold">Ollama Model Manager</h2>
<p>Update your local Ollama models to their latest versions</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:arrows-clockwise text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{models.length} models available</span>
</div>
<motion.button
onClick={handleBulkUpdate}
disabled={isBulkUpdating}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isBulkUpdating ? (
<>
<div
className={classNames(
'rounded-full border-4 border-t-4 border-b-4 border-purple-500',
'h-4 w-4 animate-spin mr-2',
)}
/>
Updating All...
</>
) : (
<>
<div className="i-ph:arrows-clockwise" />
Update All Models
</>
)}
</motion.button>
</div>
<div className="space-y-2">
{models.map((model) => (
<div
key={model.name}
className={classNames(
'flex items-center justify-between p-3 rounded-lg',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
)}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="i-ph:cube text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
{model.status === 'updating' && (
<div
className={classNames(
'rounded-full border-4 border-t-4 border-b-4 border-purple-500',
'h-4 w-4 animate-spin',
)}
/>
)}
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
<span>Version: {model.digest.substring(0, 7)}</span>
{model.status === 'updated' && model.newDigest && (
<>
<div className="i-ph:arrow-right w-3 h-3" />
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
</>
)}
{model.progress && (
<span className="ml-2">
{model.progress.status}{' '}
{model.progress.total > 0 && (
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
)}
</span>
)}
{model.details && (
<span className="ml-2">
({model.details.parameter_size}, {model.details.quantization_level})
</span>
)}
</div>
</div>
<motion.button
onClick={() => handleSingleUpdate(model.name)}
disabled={model.status === 'updating'}
className={classNames(
'rounded-md px-4 py-2 text-sm',
'bg-purple-500 text-white',
'hover:bg-purple-600',
'dark:bg-purple-500 dark:hover:bg-purple-600',
'transition-all duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update
</motion.button>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -1,234 +0,0 @@
import { motion } from 'framer-motion';
import * as Tooltip from '@radix-ui/react-tooltip';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.types';
const TAB_ICONS = {
profile: 'i-ph:user',
settings: 'i-ph:gear',
notifications: 'i-ph:bell',
features: 'i-ph:star',
data: 'i-ph:database',
providers: 'i-ph:plug',
connection: 'i-ph:wifi-high',
debug: 'i-ph:bug',
'event-logs': 'i-ph:list-bullets',
update: 'i-ph:arrow-clockwise',
'task-manager': 'i-ph:activity',
'cloud-providers': 'i-ph:cloud',
'local-providers': 'i-ph:desktop',
'service-status': 'i-ph:activity-bold',
};
interface TabTileProps {
tab: TabVisibilityConfig;
onClick: () => void;
isActive?: boolean;
hasUpdate?: boolean;
statusMessage?: string;
description?: string;
isLoading?: boolean;
}
export const TabTile = ({
tab,
onClick,
isActive = false,
hasUpdate = false,
statusMessage,
description,
isLoading = false,
}: TabTileProps) => {
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<motion.button
onClick={onClick}
disabled={isLoading}
className={classNames(
'relative flex flex-col items-center justify-center gap-3 p-6 rounded-xl',
'w-full h-full min-h-[160px]',
// Background and border styles
'bg-white dark:bg-[#141414]',
'border border-[#E5E5E5]/50 dark:border-[#333333]/50',
// Shadow and glass effect
'shadow-sm',
'dark:shadow-[0_0_15px_rgba(0,0,0,0.1)]',
'dark:bg-opacity-50',
// Hover effects
'hover:border-purple-500/30 dark:hover:border-purple-500/30',
'hover:bg-gradient-to-br hover:from-purple-50/50 hover:to-white dark:hover:from-purple-500/5 dark:hover:to-[#141414]',
'hover:shadow-md hover:shadow-purple-500/5',
'dark:hover:shadow-purple-500/10',
// Focus states for keyboard navigation
'focus:outline-none',
'focus:ring-2 focus:ring-purple-500/50 focus:ring-offset-2',
'dark:focus:ring-offset-[#141414]',
'focus:border-purple-500/30',
// Active state
isActive
? [
'border-purple-500/50 dark:border-purple-500/50',
'bg-gradient-to-br from-purple-50 to-white dark:from-purple-500/10 dark:to-[#141414]',
'shadow-md shadow-purple-500/10',
]
: '',
// Loading state
isLoading ? 'cursor-wait opacity-70' : '',
// Transitions
'transition-all duration-300 ease-out',
'group',
)}
whileHover={
!isLoading
? {
scale: 1.02,
transition: { duration: 0.2, ease: 'easeOut' },
}
: {}
}
whileTap={
!isLoading
? {
scale: 0.98,
transition: { duration: 0.1, ease: 'easeIn' },
}
: {}
}
>
{/* Loading Overlay */}
{isLoading && (
<motion.div
className={classNames(
'absolute inset-0 rounded-xl z-10',
'bg-white/50 dark:bg-black/50',
'backdrop-blur-sm',
'flex items-center justify-center',
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<motion.div
className={classNames('w-8 h-8 rounded-full', 'border-2 border-purple-500/30', 'border-t-purple-500')}
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
/>
</motion.div>
)}
{/* Status Indicator */}
{hasUpdate && (
<motion.div
className={classNames(
'absolute top-3 right-3',
'w-2.5 h-2.5 rounded-full',
'bg-green-500',
'shadow-lg shadow-green-500/20',
'ring-4 ring-green-500/20',
)}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.5 }}
/>
)}
{/* Background glow effect */}
<div
className={classNames(
'absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100',
'bg-gradient-to-br from-purple-500/5 to-transparent dark:from-purple-500/10',
'transition-opacity duration-300',
isActive ? 'opacity-100' : '',
)}
/>
{/* Icon */}
<div
className={classNames(
TAB_ICONS[tab.id],
'w-12 h-12',
'relative',
'text-gray-600 dark:text-gray-300',
'group-hover:text-purple-500 dark:group-hover:text-purple-400',
'transition-all duration-300',
isActive ? 'text-purple-500 dark:text-purple-400 scale-110' : '',
)}
/>
{/* Label and Description */}
<div className="relative flex flex-col items-center text-center">
<div
className={classNames(
'text-base font-medium',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-500 dark:group-hover:text-purple-400',
'transition-colors duration-300',
isActive ? 'text-purple-500 dark:text-purple-400' : '',
)}
>
{TAB_LABELS[tab.id]}
</div>
{description && (
<div
className={classNames(
'text-xs mt-1',
'text-gray-500 dark:text-gray-400',
'group-hover:text-purple-400/70 dark:group-hover:text-purple-300/70',
'transition-colors duration-300',
'max-w-[180px]',
isActive ? 'text-purple-400/70 dark:text-purple-300/70' : '',
)}
>
{description}
</div>
)}
</div>
{/* Bottom indicator line */}
<div
className={classNames(
'absolute bottom-0 left-1/2 -translate-x-1/2',
'w-12 h-0.5 rounded-full',
'bg-purple-500/0 group-hover:bg-purple-500/50',
'transition-all duration-300 ease-out',
'transform scale-x-0 group-hover:scale-x-100',
isActive ? 'bg-purple-500 scale-x-100' : '',
)}
/>
</motion.button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'shadow-xl',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage || TAB_LABELS[tab.id]}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@ -1,25 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
export interface ProfileHeaderProps {
onNavigate: Dispatch<SetStateAction<TabType | null>>;
visibleTabs: TabVisibilityConfig[];
}
export { type TabType };
export const ProfileHeader = ({ onNavigate, visibleTabs }: ProfileHeaderProps) => {
return (
<div className="flex items-center gap-2">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => onNavigate(tab.id)}
className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
>
{tab.id}
</button>
))}
</div>
);
};

View File

@ -1,635 +0,0 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion, AnimatePresence } from 'framer-motion';
import React, { useState, useEffect, useMemo } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle } from '~/components/ui/Dialog';
import { Switch } from '~/components/ui/Switch';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.types';
import { DeveloperWindow } from '~/components/settings/developer/DeveloperWindow';
import { TabTile } from '~/components/settings/shared/TabTile';
import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import ProfileTab from '~/components/settings/profile/ProfileTab';
import SettingsTab from '~/components/settings/settings/SettingsTab';
import NotificationsTab from '~/components/settings/notifications/NotificationsTab';
import FeaturesTab from '~/components/settings/features/FeaturesTab';
import DataTab from '~/components/settings/data/DataTab';
import DebugTab from '~/components/settings/debug/DebugTab';
import { EventLogsTab } from '~/components/settings/event-logs/EventLogsTab';
import UpdateTab from '~/components/settings/update/UpdateTab';
import ConnectionsTab from '~/components/settings/connections/ConnectionsTab';
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
import { useFeatures } from '~/lib/hooks/useFeatures';
import { useNotifications } from '~/lib/hooks/useNotifications';
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab';
import ServiceStatusTab from '~/components/settings/providers/ServiceStatusTab';
import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab';
import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab';
import {
tabConfigurationStore,
resetTabConfiguration,
updateTabConfiguration,
developerModeStore,
setDeveloperMode,
} from '~/lib/stores/settings';
import { DEFAULT_TAB_CONFIG } from '~/components/settings/settings.types';
interface DraggableTabTileProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onClick: () => void;
isActive: boolean;
hasUpdate: boolean;
statusMessage: string;
description: string;
isLoading?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
profile: 'Manage your profile and account settings',
settings: 'Configure application preferences',
notifications: 'View and manage your notifications',
features: 'Explore new and upcoming features',
data: 'Manage your data and storage',
'cloud-providers': 'Configure cloud AI providers and models',
'local-providers': 'Configure local AI providers and models',
'service-status': 'Monitor cloud LLM service status',
connection: 'Check connection status and settings',
debug: 'Debug tools and system information',
'event-logs': 'View system events and logs',
update: 'Check for updates and release notes',
'task-manager': 'Monitor system resources and processes',
};
const DraggableTabTile = ({
tab,
index,
moveTab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
}: DraggableTabTileProps) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'tab',
hover: (item: { index: number }) => {
if (item.index === index) {
return;
}
moveTab(item.index, index);
item.index = index;
},
});
const dragDropRef = (node: HTMLDivElement | null) => {
if (node) {
drag(drop(node));
}
};
return (
<div ref={dragDropRef} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile
tab={tab}
onClick={onClick}
isActive={isActive}
hasUpdate={hasUpdate}
statusMessage={statusMessage}
description={description}
isLoading={isLoading}
/>
</div>
);
};
interface UsersWindowProps {
open: boolean;
onClose: () => void;
}
interface TabWithType extends TabVisibilityConfig {
isExtraDevTab?: boolean;
}
export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const tabConfiguration = useStore(tabConfigurationStore);
const developerMode = useStore(developerModeStore);
const [showDeveloperWindow, setShowDeveloperWindow] = useState(false);
const [profile, setProfile] = useState(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved ? JSON.parse(saved) : { avatar: null, notifications: true };
});
// Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Listen for profile changes
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'bolt_user_profile') {
const newProfile = e.newValue ? JSON.parse(e.newValue) : { avatar: null, notifications: true };
setProfile(newProfile);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
// Listen for settings toggle event
useEffect(() => {
const handleToggleSettings = () => {
if (!open) {
// Open settings panel
setActiveTab('settings');
onClose(); // Close any other open panels
}
};
document.addEventListener('toggle-settings', handleToggleSettings);
return () => document.removeEventListener('toggle-settings', handleToggleSettings);
}, [open, onClose]);
// Ensure tab configuration is properly initialized
useEffect(() => {
if (!tabConfiguration || !tabConfiguration.userTabs || !tabConfiguration.developerTabs) {
console.warn('Tab configuration is invalid, resetting to defaults');
resetTabConfiguration();
} else {
// Validate tab configuration structure
const isValid =
tabConfiguration.userTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
) &&
tabConfiguration.developerTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
);
if (!isValid) {
console.warn('Tab configuration is malformed, resetting to defaults');
resetTabConfiguration();
}
}
}, [tabConfiguration]);
// Handle developer mode changes
const handleDeveloperModeChange = (checked: boolean) => {
setDeveloperMode(checked);
if (checked) {
setShowDeveloperWindow(true);
}
};
// Handle developer window close
const handleDeveloperWindowClose = () => {
setShowDeveloperWindow(false);
setDeveloperMode(false);
};
const handleBack = () => {
setActiveTab(null);
};
// Only show tabs that are assigned to the user window AND are visible
const visibleUserTabs = useMemo(() => {
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, using empty array');
return [];
}
// Get the base user tabs that are visible
const baseTabs = 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' && !profile.notifications) {
return false;
}
// Only show tabs that are explicitly visible and assigned to the user window
return tab.visible && tab.window === 'user';
});
// If in developer mode, add the developer-only tabs
if (developerMode) {
const developerOnlyTabs = DEFAULT_TAB_CONFIG.filter((tab) => {
/*
* Only include tabs that are:
* 1. Assigned to developer window
* 2. Not already in user tabs
* 3. Marked as visible in developer window
*/
return tab.window === 'developer' && tab.visible && !baseTabs.some((baseTab) => baseTab.id === tab.id);
}).map((tab) => ({
...tab,
isExtraDevTab: true,
order: baseTabs.length + tab.order, // Place after user tabs
}));
return [...baseTabs, ...developerOnlyTabs].sort((a, b) => a.order - b.order);
}
return baseTabs.sort((a, b) => a.order - b.order);
}, [tabConfiguration, profile.notifications, developerMode]);
const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleUserTabs[dragIndex];
const targetTab = visibleUserTabs[hoverIndex];
console.log('Moving tab:', { draggedTab, targetTab });
// Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order };
// Update both tabs in the store
updateTabConfiguration(updatedDraggedTab);
updateTabConfiguration(updatedTargetTab);
};
const handleTabClick = async (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
// Acknowledge the status based on tab type
switch (tabId) {
case 'update':
await acknowledgeUpdate();
break;
case 'features':
await acknowledgeAllFeatures();
break;
case 'notifications':
await markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
await acknowledgeAllIssues();
break;
}
// Simulate loading time (remove this in production)
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoadingTab(null);
};
const getTabComponent = () => {
switch (activeTab) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'cloud-providers':
return <CloudProvidersTab />;
case 'service-status':
return <ServiceStatusTab />;
case 'local-providers':
return <LocalProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
case 'task-manager':
return <TaskManagerTab />;
default:
return null;
}
};
const getTabUpdateStatus = (tabId: TabType): boolean => {
switch (tabId) {
case 'update':
return hasUpdate;
case 'features':
return hasNewFeatures;
case 'notifications':
return hasUnreadNotifications;
case 'connection':
return hasConnectionIssues;
case 'debug':
return hasActiveWarnings;
default:
return false;
}
};
const getStatusMessage = (tabId: TabType): string => {
switch (tabId) {
case 'update':
return `New update available (v${currentVersion})`;
case 'features':
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
case 'notifications':
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
case 'connection':
return currentIssue === 'disconnected'
? 'Connection lost'
: currentIssue === 'high-latency'
? 'High latency detected'
: 'Connection issues detected';
case 'debug': {
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
const errors = activeIssues.filter((i) => i.type === 'error').length;
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
}
default:
return '';
}
};
return (
<>
<DeveloperWindow open={showDeveloperWindow} onClose={handleDeveloperWindowClose} />
<DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content
aria-describedby={undefined}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
className="relative z-[101]"
>
<motion.div
className={classNames(
'relative',
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
'z-[51]',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
{activeTab ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
) : (
<motion.div
className="i-ph:lightning-fill w-5 h-5 text-purple-500"
initial={{ rotate: -10 }}
animate={{ rotate: 10 }}
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2,
ease: 'easeInOut',
}}
/>
)}
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
{activeTab ? TAB_LABELS[activeTab] : 'Bolt Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center gap-2">
<Switch
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className="data-[state=checked]:bg-purple-500"
aria-label="Toggle developer mode"
/>
<label className="text-sm text-gray-500 dark:text-gray-400">Switch to Developer Mode</label>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center justify-center w-8 h-8 rounded-full overflow-hidden hover:ring-2 ring-gray-300 dark:ring-gray-600 transition-all">
{profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-[200] animate-in fade-in-0 zoom-in-95"
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('profile')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:user-circle w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('settings')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:gear w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Settings</span>
</DropdownMenu.Item>
{profile.notifications && (
<>
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={() => handleTabClick('notifications')}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:bell w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">
Notifications
{hasUnreadNotifications && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-purple-500 text-white rounded-full">
{unreadNotifications.length}
</span>
)}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
</>
)}
<DropdownMenu.Item
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onSelect={onClose}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div className="i-ph:sign-out w-[18px] h-[18px] text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</div>
<span className="group-hover:text-purple-500 transition-colors">Close</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
onClick={onClose}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6"
>
{activeTab ? (
getTabComponent()
) : (
<motion.div
className="grid grid-cols-4 gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<AnimatePresence mode="popLayout">
{visibleUserTabs.map((tab: TabWithType, index: number) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.05,
}}
>
<DraggableTabTile
tab={tab}
index={index}
moveTab={moveTab}
onClick={() => handleTabClick(tab.id)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
</DndProvider>
</>
);
};

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { UsersWindow } from '~/components/settings/user/UsersWindow';
import { ControlPanel } from '~/components/@settings/core/ControlPanel';
import { SettingsButton } from '~/components/ui/SettingsButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
@ -260,7 +260,7 @@ export const Menu = () => {
</div>
</motion.div>
<UsersWindow open={isSettingsOpen} onClose={handleSettingsClose} />
<ControlPanel open={isSettingsOpen} onClose={handleSettingsClose} />
</>
);
};

View File

@ -17,7 +17,7 @@ import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import { PushToGitHubDialog } from '~/components/settings/connections/components/PushToGitHubDialog';
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
interface WorkspaceProps {
chatStarted?: boolean;

View File

@ -5,14 +5,59 @@ export interface ConnectionStatus {
}
export const checkConnection = async (): Promise<ConnectionStatus> => {
/*
* TODO: Implement actual connection check logic
* This is a mock implementation
*/
const connected = Math.random() > 0.1; // 90% chance of being connected
return {
connected,
latency: connected ? Math.floor(Math.random() * 1500) : 0, // Random latency between 0-1500ms
lastChecked: new Date().toISOString(),
};
try {
// Check if we have network connectivity
const online = navigator.onLine;
if (!online) {
return {
connected: false,
latency: 0,
lastChecked: new Date().toISOString(),
};
}
// Try multiple endpoints in case one fails
const endpoints = [
'/api/health',
'/', // Fallback to root route
'/favicon.ico', // Another common fallback
];
let latency = 0;
let connected = false;
for (const endpoint of endpoints) {
try {
const start = performance.now();
const response = await fetch(endpoint, {
method: 'HEAD',
cache: 'no-cache',
});
const end = performance.now();
if (response.ok) {
latency = Math.round(end - start);
connected = true;
break;
}
} catch (endpointError) {
console.debug(`Failed to connect to ${endpoint}:`, endpointError);
continue;
}
}
return {
connected,
latency,
lastChecked: new Date().toISOString(),
};
} catch (error) {
console.error('Connection check failed:', error);
return {
connected: false,
latency: 0,
lastChecked: new Date().toISOString(),
};
}
};

View File

@ -13,53 +13,109 @@ export interface DebugError {
}
export interface DebugStatus {
warnings: DebugWarning[];
errors: DebugError[];
lastChecked: string;
warnings: DebugIssue[];
errors: DebugIssue[];
}
export interface DebugIssue {
id: string;
type: 'warning' | 'error';
message: string;
type: 'warning' | 'error';
timestamp: string;
details?: Record<string, unknown>;
}
// Keep track of acknowledged issues
const acknowledgedIssues = new Set<string>();
export const getDebugStatus = async (): Promise<DebugStatus> => {
/*
* TODO: Implement actual debug status logic
* This is a mock implementation
*/
return {
warnings: [
{
id: 'warn-1',
message: 'High memory usage detected',
timestamp: new Date().toISOString(),
code: 'MEM_HIGH',
},
],
errors: [
{
id: 'err-1',
message: 'Failed to connect to database',
timestamp: new Date().toISOString(),
stack: 'Error: Connection timeout',
},
],
lastChecked: new Date().toISOString(),
const issues: DebugStatus = {
warnings: [],
errors: [],
};
try {
// Check memory usage
if (performance && 'memory' in performance) {
const memory = (performance as any).memory;
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.8) {
issues.warnings.push({
id: 'high-memory-usage',
message: 'High memory usage detected',
type: 'warning',
timestamp: new Date().toISOString(),
details: {
used: memory.usedJSHeapSize,
total: memory.jsHeapSizeLimit,
},
});
}
}
// Check storage quota
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const usageRatio = (estimate.usage || 0) / (estimate.quota || 1);
if (usageRatio > 0.9) {
issues.warnings.push({
id: 'storage-quota-warning',
message: 'Storage quota nearly reached',
type: 'warning',
timestamp: new Date().toISOString(),
details: {
used: estimate.usage,
quota: estimate.quota,
},
});
}
}
// Check for console errors (if any)
const errorLogs = localStorage.getItem('error_logs');
if (errorLogs) {
const errors = JSON.parse(errorLogs);
errors.forEach((error: any) => {
issues.errors.push({
id: `error-${error.timestamp}`,
message: error.message,
type: 'error',
timestamp: error.timestamp,
details: error.details,
});
});
}
// Filter out acknowledged issues
issues.warnings = issues.warnings.filter((warning) => !acknowledgedIssues.has(warning.id));
issues.errors = issues.errors.filter((error) => !acknowledgedIssues.has(error.id));
return issues;
} catch (error) {
console.error('Error getting debug status:', error);
return issues;
}
};
export const acknowledgeWarning = async (warningId: string): Promise<void> => {
/*
* TODO: Implement actual warning acknowledgment logic
*/
console.log(`Acknowledging warning ${warningId}`);
export const acknowledgeWarning = async (id: string): Promise<void> => {
acknowledgedIssues.add(id);
};
export const acknowledgeError = async (errorId: string): Promise<void> => {
/*
* TODO: Implement actual error acknowledgment logic
*/
console.log(`Acknowledging error ${errorId}`);
export const acknowledgeError = async (id: string): Promise<void> => {
acknowledgedIssues.add(id);
// Also remove from error logs if present
try {
const errorLogs = localStorage.getItem('error_logs');
if (errorLogs) {
const errors = JSON.parse(errorLogs);
const updatedErrors = errors.filter((error: any) => `error-${error.timestamp}` !== id);
localStorage.setItem('error_logs', JSON.stringify(updatedErrors));
}
} catch (error) {
console.error('Error acknowledging error:', error);
}
};

View File

@ -1,71 +1,35 @@
import { logStore, type LogEntry } from '~/lib/stores/logs';
export type NotificationType = 'info' | 'warning' | 'error' | 'success' | 'update';
export interface NotificationDetails {
type?: string;
message?: string;
currentVersion?: string;
latestVersion?: string;
branch?: string;
updateUrl?: string;
}
import { logStore } from '~/lib/stores/logs';
import type { LogEntry } from '~/lib/stores/logs';
export interface Notification {
id: string;
title: string;
message: string;
type: NotificationType;
read: boolean;
type: 'info' | 'warning' | 'error' | 'success';
timestamp: string;
details?: NotificationDetails;
read: boolean;
details?: Record<string, unknown>;
}
interface LogEntryWithRead extends LogEntry {
read?: boolean;
export interface LogEntryWithRead extends LogEntry {
read: boolean;
}
const mapLogToNotification = (log: LogEntryWithRead): Notification => {
const type: NotificationType =
log.details?.type === 'update'
? 'update'
: log.level === 'error'
? 'error'
: log.level === 'warning'
? 'warning'
: 'info';
const baseNotification: Notification = {
id: log.id,
title: log.category.charAt(0).toUpperCase() + log.category.slice(1),
message: log.message,
type,
read: log.read || false,
timestamp: log.timestamp,
};
if (log.details) {
return {
...baseNotification,
details: log.details as NotificationDetails,
};
}
return baseNotification;
};
export const getNotifications = async (): Promise<Notification[]> => {
const logs = Object.values(logStore.logs.get()) as LogEntryWithRead[];
// Get notifications from the log store
const logs = Object.values(logStore.logs.get());
return logs
.filter((log) => {
if (log.details?.type === 'update') {
return true;
}
return log.level === 'error' || log.level === 'warning';
})
.map(mapLogToNotification)
.filter((log) => log.category !== 'system') // Filter out system logs
.map((log) => ({
id: log.id,
title: (log.details?.title as string) || log.message.split('\n')[0],
message: log.message,
type: log.level as 'info' | 'warning' | 'error' | 'success',
timestamp: log.timestamp,
read: logStore.isRead(log.id),
details: log.details,
}))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
};
@ -81,7 +45,7 @@ export const getUnreadCount = (): number => {
const logs = Object.values(logStore.logs.get()) as LogEntryWithRead[];
return logs.filter((log) => {
if (!log.read) {
if (!logStore.isRead(log.id)) {
if (log.details?.type === 'update') {
return true;
}

View File

@ -2,27 +2,107 @@ export interface UpdateCheckResult {
available: boolean;
version: string;
releaseNotes?: string;
error?: {
type: 'rate_limit' | 'network' | 'auth' | 'unknown';
message: string;
};
}
interface PackageJson {
version: string;
name: string;
[key: string]: unknown;
}
function compareVersions(v1: string, v2: string): number {
// Remove 'v' prefix if present
const version1 = v1.replace(/^v/, '');
const version2 = v2.replace(/^v/, '');
const parts1 = version1.split('.').map(Number);
const parts2 = version2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 !== part2) {
return part1 - part2;
}
}
return 0;
}
export const checkForUpdates = async (): Promise<UpdateCheckResult> => {
/*
* TODO: Implement actual update check logic
* This is a mock implementation
*/
return {
available: Math.random() > 0.7, // 30% chance of update
version: '1.0.1',
releaseNotes: 'Bug fixes and performance improvements',
};
try {
// Get the current version from local package.json
const packageResponse = await fetch('/package.json');
if (!packageResponse.ok) {
throw new Error('Failed to fetch local package.json');
}
const packageData = (await packageResponse.json()) as PackageJson;
if (!packageData.version || typeof packageData.version !== 'string') {
throw new Error('Invalid package.json format: missing or invalid version');
}
const currentVersion = packageData.version;
/*
* Get the latest version from GitHub's main branch package.json
* Using raw.githubusercontent.com which doesn't require authentication
*/
const latestPackageResponse = await fetch(
'https://raw.githubusercontent.com/stackblitz-labs/bolt.diy/main/package.json',
);
if (!latestPackageResponse.ok) {
throw new Error(`Failed to fetch latest package.json: ${latestPackageResponse.status}`);
}
const latestPackageData = (await latestPackageResponse.json()) as PackageJson;
if (!latestPackageData.version || typeof latestPackageData.version !== 'string') {
throw new Error('Invalid remote package.json format: missing or invalid version');
}
const latestVersion = latestPackageData.version;
// Compare versions semantically
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
return {
available: hasUpdate,
version: latestVersion,
releaseNotes: hasUpdate ? 'Update available. Check GitHub for release notes.' : undefined,
};
} catch (error) {
console.error('Error checking for updates:', error);
// Determine error type
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
const isNetworkError =
errorMessage.toLowerCase().includes('network') || errorMessage.toLowerCase().includes('fetch');
return {
available: false,
version: 'unknown',
error: {
type: isNetworkError ? 'network' : 'unknown',
message: `Failed to check for updates: ${errorMessage}`,
},
};
}
};
export const acknowledgeUpdate = async (version: string): Promise<void> => {
/*
* TODO: Implement actual update acknowledgment logic
* This is a mock implementation that would typically:
* 1. Store the acknowledged version in a persistent store
* 2. Update the UI state
* 3. Potentially send analytics
*/
console.log(`Acknowledging update version ${version}`);
// Store the acknowledged version in localStorage
try {
localStorage.setItem('last_acknowledged_update', version);
} catch (error) {
console.error('Failed to store acknowledged version:', error);
}
};

View File

@ -23,7 +23,7 @@ import {
import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo, IProviderConfig } from '~/types/model';
import type { TabWindowConfig, TabVisibilityConfig } from '~/components/settings/settings.types';
import type { TabWindowConfig, TabVisibilityConfig } from '~/components/@settings/core/types';
import { logStore } from '~/lib/stores/logs';
import { getLocalStorage, setLocalStorage } from '~/lib/persistence';

28
app/lib/stores/profile.ts Normal file
View File

@ -0,0 +1,28 @@
import { atom } from 'nanostores';
interface Profile {
username: string;
bio: string;
avatar: string;
}
// Initialize with stored profile or defaults
const storedProfile = typeof window !== 'undefined' ? localStorage.getItem('bolt_profile') : null;
const initialProfile: Profile = storedProfile
? JSON.parse(storedProfile)
: {
username: '',
bio: '',
avatar: '',
};
export const profileStore = atom<Profile>(initialProfile);
export const updateProfile = (updates: Partial<Profile>) => {
profileStore.set({ ...profileStore.get(), ...updates });
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('bolt_profile', JSON.stringify(profileStore.get()));
}
};

View File

@ -2,8 +2,13 @@ import { atom, map } from 'nanostores';
import { workbenchStore } from './workbench';
import { PROVIDER_LIST } from '~/utils/constants';
import type { IProviderConfig } from '~/types/model';
import type { TabVisibilityConfig, TabWindowConfig } from '~/components/settings/settings.types';
import { DEFAULT_TAB_CONFIG } from '~/components/settings/settings.types';
import type {
TabVisibilityConfig,
TabWindowConfig,
UserTabConfig,
DevTabConfig,
} from '~/components/@settings/core/types';
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
import Cookies from 'js-cookie';
import { toggleTheme } from './theme';
import { chatStore } from './chat';
@ -211,8 +216,8 @@ export const updatePromptId = (id: string) => {
// Initialize tab configuration from localStorage or defaults
const getInitialTabConfiguration = (): TabWindowConfig => {
const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
};
if (!isBrowser) {
@ -232,7 +237,13 @@ const getInitialTabConfiguration = (): TabWindowConfig => {
return defaultConfig;
}
return parsed;
// Ensure proper typing of loaded configuration
return {
userTabs: parsed.userTabs.filter((tab: TabVisibilityConfig): tab is UserTabConfig => tab.window === 'user'),
developerTabs: parsed.developerTabs.filter(
(tab: TabVisibilityConfig): tab is DevTabConfig => tab.window === 'developer',
),
};
} catch (error) {
console.warn('Failed to parse tab configuration:', error);
return defaultConfig;
@ -277,21 +288,13 @@ export const updateTabConfiguration = (config: TabVisibilityConfig) => {
// Helper function to reset tab configuration
export const resetTabConfiguration = () => {
console.log('Resetting tab configuration to defaults');
const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
};
console.log('Default tab configuration:', defaultConfig);
tabConfigurationStore.set(defaultConfig);
Cookies.set('tabConfiguration', JSON.stringify(defaultConfig), {
expires: 365, // Set cookie to expire in 1 year
path: '/',
sameSite: 'strict',
});
localStorage.setItem('bolt_tab_configuration', JSON.stringify(defaultConfig));
};
// Developer mode store with persistence

View File

@ -0,0 +1,32 @@
import { create } from 'zustand';
export interface TabConfig {
id: string;
visible: boolean;
window: 'developer' | 'user';
order: number;
locked?: boolean;
}
interface TabConfigurationStore {
userTabs: TabConfig[];
developerTabs: TabConfig[];
get: () => { userTabs: TabConfig[]; developerTabs: TabConfig[] };
set: (config: { userTabs: TabConfig[]; developerTabs: TabConfig[] }) => void;
reset: () => void;
}
const DEFAULT_CONFIG = {
userTabs: [],
developerTabs: [],
};
export const tabConfigurationStore = create<TabConfigurationStore>((set, get) => ({
...DEFAULT_CONFIG,
get: () => ({
userTabs: get().userTabs,
developerTabs: get().developerTabs,
}),
set: (config) => set(config),
reset: () => set(DEFAULT_CONFIG),
}));

View File

@ -4,7 +4,8 @@ import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { ControlPanel } from '~/components/settings/ControlPanel';
import { ControlPanel } from '~/components/@settings';
import { SettingsButton } from '~/components/ui/SettingsButton';
import { useState } from 'react';
export const meta: MetaFunction = () => {
@ -21,13 +22,9 @@ export default function Index() {
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
<button
onClick={() => setShowControlPanel(true)}
className="fixed bottom-4 right-4 flex items-center space-x-2 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
>
<span className="i-ph:gear w-5 h-5" />
<span>Open Control Panel</span>
</button>
<div className="fixed bottom-4 right-4">
<SettingsButton onClick={() => setShowControlPanel(true)} />
</div>
<ClientOnly>
{() => <ControlPanel open={showControlPanel} onClose={() => setShowControlPanel(false)} />}
</ClientOnly>

18
app/routes/api.health.ts Normal file
View File

@ -0,0 +1,18 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
// Return a simple 200 OK response with some basic health information
return new Response(
JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
);
};

45
app/routes/api.update.ts Normal file
View File

@ -0,0 +1,45 @@
import { json } from '@remix-run/node';
import type { ActionFunction } from '@remix-run/node';
interface UpdateRequestBody {
branch: string;
}
export const action: ActionFunction = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = await request.json();
// Type guard to check if body has the correct shape
if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') {
return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 });
}
const { branch } = body as UpdateRequestBody;
// Instead of direct Git operations, we'll return instructions
return json({
success: true,
message: 'Please update manually using the following steps:',
instructions: [
`1. git fetch origin ${branch}`,
`2. git pull origin ${branch}`,
'3. pnpm install',
'4. pnpm build',
'5. Restart the application',
],
});
} catch (error) {
console.error('Update preparation failed:', error);
return json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred while preparing update',
},
{ status: 500 },
);
}
};

View File

@ -124,7 +124,8 @@
"remix-utils": "^7.7.0",
"shiki": "^1.24.0",
"tailwind-merge": "^2.2.1",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@blitz/eslint-plugin": "0.1.0",

27
pnpm-lock.yaml generated
View File

@ -293,6 +293,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1))
devDependencies:
'@blitz/eslint-plugin':
specifier: 0.1.0
@ -6648,6 +6651,24 @@ packages:
zod@3.24.1:
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -14074,4 +14095,10 @@ snapshots:
zod@3.24.1: {}
zustand@5.0.3(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.18
react: 18.3.1
use-sync-external-store: 1.4.0(react@18.3.1)
zwitch@2.0.4: {}

7
scripts/update-imports.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# Update imports in TypeScript files
find app -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i '' 's|~/components/settings/settings.types|~/components/@settings/core/types|g'
# Update imports for specific components
find app -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i '' 's|~/components/settings/|~/components/@settings/tabs/|g'