mirror of
https://github.com/stackblitz-labs/bolt.diy.git
synced 2025-06-18 01:49:07 +01:00
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:
parent
999d87b1e8
commit
fc3dd8c84c
1
.gitignore
vendored
1
.gitignore
vendored
@ -44,3 +44,4 @@ changelogUI.md
|
||||
docs/instructions/Roadmap.md
|
||||
.cursorrules
|
||||
.cursorrules
|
||||
*.md
|
||||
|
181
app/components/@settings/core/AvatarDropdown.tsx
Normal file
181
app/components/@settings/core/AvatarDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
459
app/components/@settings/core/ControlPanel.tsx
Normal file
459
app/components/@settings/core/ControlPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
88
app/components/@settings/core/constants.ts
Normal file
88
app/components/@settings/core/constants.ts
Normal 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 },
|
||||
];
|
@ -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;
|
||||
};
|
||||
}
|
14
app/components/@settings/index.ts
Normal file
14
app/components/@settings/index.ts
Normal 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';
|
@ -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 {
|
259
app/components/@settings/shared/components/TabManagement.tsx
Normal file
259
app/components/@settings/shared/components/TabManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
162
app/components/@settings/shared/components/TabTile.tsx
Normal file
162
app/components/@settings/shared/components/TabTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
@ -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 {
|
@ -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} />
|
613
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
Normal file
613
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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(
|
174
app/components/@settings/tabs/profile/ProfileTab.tsx
Normal file
174
app/components/@settings/tabs/profile/ProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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> {
|
@ -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';
|
||||
|
@ -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 => {
|
@ -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>
|
41
app/components/@settings/utils/animations.ts
Normal file
41
app/components/@settings/utils/animations.ts
Normal 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,
|
||||
};
|
89
app/components/@settings/utils/tab-helpers.ts
Normal file
89
app/components/@settings/utils/tab-helpers.ts
Normal 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[];
|
||||
};
|
@ -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([]);
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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
28
app/lib/stores/profile.ts
Normal 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()));
|
||||
}
|
||||
};
|
@ -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
|
||||
|
32
app/lib/stores/tabConfigurationStore.ts
Normal file
32
app/lib/stores/tabConfigurationStore.ts
Normal 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),
|
||||
}));
|
@ -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
18
app/routes/api.health.ts
Normal 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
45
app/routes/api.update.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
};
|
@ -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
27
pnpm-lock.yaml
generated
@ -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
7
scripts/update-imports.sh
Executable 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'
|
Loading…
x
Reference in New Issue
Block a user