Settings UI enhancement

Date & Time Display
Added a real-time clock component in the sidebar

Event Logs System
Implemented an EventLogsTab component for system monitoring
Provides a structured way to:
Track user interactions
Monitor system events
Display activity history
This commit is contained in:
Stijnus 2024-12-13 01:11:35 +01:00
parent e716ca55f0
commit e39f16e436
15 changed files with 450 additions and 26 deletions

View File

@ -3,6 +3,7 @@ import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils'; import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport'; import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
interface ImportFolderButtonProps { interface ImportFolderButtonProps {
className?: string; className?: string;
@ -16,9 +17,15 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
const allFiles = Array.from(e.target.files || []); const allFiles = Array.from(e.target.files || []);
if (allFiles.length > MAX_FILES) { if (allFiles.length > MAX_FILES) {
const error = new Error(`Too many files: ${allFiles.length}`);
logStore.logError('File import failed - too many files', error, {
fileCount: allFiles.length,
maxFiles: MAX_FILES,
});
toast.error( toast.error(
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`, `This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
); );
return; return;
} }
@ -31,7 +38,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath)); const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
if (filteredFiles.length === 0) { if (filteredFiles.length === 0) {
const error = new Error('No valid files found');
logStore.logError('File import failed - no valid files', error, { folderName });
toast.error('No files found in the selected folder'); toast.error('No files found in the selected folder');
return; return;
} }
@ -48,11 +58,18 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/')); .map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
if (textFiles.length === 0) { if (textFiles.length === 0) {
const error = new Error('No text files found');
logStore.logError('File import failed - no text files', error, { folderName });
toast.error('No text files found in the selected folder'); toast.error('No text files found in the selected folder');
return; return;
} }
if (binaryFilePaths.length > 0) { if (binaryFilePaths.length > 0) {
logStore.logWarning(`Skipping binary files during import`, {
folderName,
binaryCount: binaryFilePaths.length,
});
toast.info(`Skipping ${binaryFilePaths.length} binary files`); toast.info(`Skipping ${binaryFilePaths.length} binary files`);
} }
@ -62,8 +79,14 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
await importChat(folderName, [...messages]); await importChat(folderName, [...messages]);
} }
logStore.logSystem('Folder imported successfully', {
folderName,
textFileCount: textFiles.length,
binaryFileCount: binaryFilePaths.length,
});
toast.success('Folder imported successfully'); toast.success('Folder imported successfully');
} catch (error) { } catch (error) {
logStore.logError('Failed to import folder', error, { folderName });
console.error('Failed to import folder:', error); console.error('Failed to import folder:', error);
toast.error('Failed to import folder'); toast.error('Failed to import folder');
} finally { } finally {

View File

@ -10,6 +10,7 @@ import ProvidersTab from './providers/ProvidersTab';
import { useSettings } from '~/lib/hooks/useSettings'; import { useSettings } from '~/lib/hooks/useSettings';
import FeaturesTab from './features/FeaturesTab'; import FeaturesTab from './features/FeaturesTab';
import DebugTab from './debug/DebugTab'; import DebugTab from './debug/DebugTab';
import EventLogsTab from './event-logs/EventLogsTab';
import ConnectionsTab from './connections/ConnectionsTab'; import ConnectionsTab from './connections/ConnectionsTab';
interface SettingsProps { interface SettingsProps {
@ -17,11 +18,10 @@ interface SettingsProps {
onClose: () => void; onClose: () => void;
} }
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'connection'; type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
// Providers that support base URL configuration
export const SettingsWindow = ({ open, onClose }: SettingsProps) => { export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
const { debug } = useSettings(); const { debug, eventLogs } = useSettings();
const [activeTab, setActiveTab] = useState<TabType>('chat-history'); const [activeTab, setActiveTab] = useState<TabType>('chat-history');
const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [ const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
@ -39,6 +39,16 @@ export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
}, },
] ]
: []), : []),
...(eventLogs
? [
{
id: 'event-logs' as TabType,
label: 'Event Logs',
icon: 'i-ph:list-bullets',
component: <EventLogsTab />,
},
]
: []),
]; ];
return ( return (

View File

@ -4,6 +4,7 @@ import { toast } from 'react-toastify';
import { db, deleteById, getAll } from '~/lib/persistence'; import { db, deleteById, getAll } from '~/lib/persistence';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import styles from '~/components/settings/Settings.module.scss'; import styles from '~/components/settings/Settings.module.scss';
import { logStore } from '~/lib/stores/logs'; // Import logStore for event logging
export default function ChatHistoryTab() { export default function ChatHistoryTab() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -22,7 +23,10 @@ export default function ChatHistoryTab() {
const handleDeleteAllChats = async () => { const handleDeleteAllChats = async () => {
if (!db) { if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to delete chats - DB unavailable', error);
toast.error('Database is not available'); toast.error('Database is not available');
return; return;
} }
@ -30,13 +34,12 @@ export default function ChatHistoryTab() {
setIsDeleting(true); setIsDeleting(true);
const allChats = await getAll(db); const allChats = await getAll(db);
// Delete all chats one by one
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id))); await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
logStore.logSystem('All chats deleted successfully', { count: allChats.length });
toast.success('All chats deleted successfully'); toast.success('All chats deleted successfully');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (error) { } catch (error) {
logStore.logError('Failed to delete chats', error);
toast.error('Failed to delete chats'); toast.error('Failed to delete chats');
console.error(error); console.error(error);
} finally { } finally {
@ -46,7 +49,10 @@ export default function ChatHistoryTab() {
const handleExportAllChats = async () => { const handleExportAllChats = async () => {
if (!db) { if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to export chats - DB unavailable', error);
toast.error('Database is not available'); toast.error('Database is not available');
return; return;
} }
@ -58,8 +64,10 @@ export default function ChatHistoryTab() {
}; };
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`); downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
logStore.logSystem('Chats exported successfully', { count: allChats.length });
toast.success('Chats exported successfully'); toast.success('Chats exported successfully');
} catch (error) { } catch (error) {
logStore.logError('Failed to export chats', error);
toast.error('Failed to export chats'); toast.error('Failed to export chats');
console.error(error); console.error(error);
} }

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { logStore } from '~/lib/stores/logs';
export default function ConnectionsTab() { export default function ConnectionsTab() {
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
@ -9,6 +10,10 @@ export default function ConnectionsTab() {
const handleSaveConnection = () => { const handleSaveConnection = () => {
Cookies.set('githubUsername', githubUsername); Cookies.set('githubUsername', githubUsername);
Cookies.set('githubToken', githubToken); Cookies.set('githubToken', githubToken);
logStore.logSystem('GitHub connection settings updated', {
username: githubUsername,
hasToken: !!githubToken,
});
toast.success('GitHub credentials saved successfully!'); toast.success('GitHub credentials saved successfully!');
}; };

View File

@ -0,0 +1,145 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
import { Switch } from '~/components/ui/Switch';
import { logStore, type LogEntry } from '~/lib/stores/logs';
export default function EventLogsTab() {
const {} = useSettings();
const [logLevel, setLogLevel] = useState<LogEntry['level']>('info');
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [, forceUpdate] = useState({});
useEffect(() => {
// Add some initial logs for testing
logStore.logSystem('System started', { version: '1.0.0' });
logStore.logWarning('High memory usage detected', { memoryUsage: '85%' });
logStore.logError('Failed to connect to provider', new Error('Connection timeout'), { provider: 'OpenAI' });
}, []);
const handleClearLogs = useCallback(() => {
if (confirm('Are you sure you want to clear all logs?')) {
logStore.clearLogs();
toast.success('Logs cleared successfully');
forceUpdate({}); // Force a re-render after clearing logs
}
}, []);
const handleExportLogs = useCallback(() => {
try {
const logText = logStore
.getLogs()
.map(
(log) =>
`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}${
log.details ? '\nDetails: ' + JSON.stringify(log.details, null, 2) : ''
}`,
)
.join('\n\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-logs-${new Date().toISOString()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Logs exported successfully');
} catch (error) {
toast.error('Failed to export logs');
console.error('Export error:', error);
}
}, []);
const filteredLogs = useMemo(() => {
return logStore.getFilteredLogs(logLevel, undefined, searchQuery);
}, [logLevel, searchQuery]);
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'info':
return 'text-blue-500';
case 'warning':
return 'text-yellow-500';
case 'error':
return 'text-red-500';
case 'debug':
return 'text-gray-500';
default:
return 'text-bolt-elements-textPrimary';
}
};
return (
<div className="p-4">
<div className="flex flex-col space-y-4 mb-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-bolt-elements-textSecondary">Auto-scroll</span>
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
</div>
</div>
<div className="flex items-center space-x-2">
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
className="bg-bolt-elements-bg-depth-2 text-bolt-elements-textPrimary rounded-lg px-3 py-1.5 text-sm min-w-[100px]"
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-bolt-elements-bg-depth-2 text-bolt-elements-textPrimary rounded-lg px-3 py-1.5 text-sm flex-1"
/>
<button
onClick={handleExportLogs}
className="bg-blue-500 text-white rounded-lg px-3 py-1.5 hover:bg-blue-600 transition-colors duration-200 text-sm whitespace-nowrap"
>
Export Logs
</button>
<button
onClick={handleClearLogs}
className="bg-red-500 text-white rounded-lg px-3 py-1.5 hover:bg-red-600 transition-colors duration-200 text-sm whitespace-nowrap"
>
Clear Logs
</button>
</div>
</div>
<div className="bg-bolt-elements-bg-depth-1 rounded-lg p-4 h-[500px] overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="text-center text-bolt-elements-textSecondary py-8">No logs found</div>
) : (
filteredLogs.map((log, index) => (
<div
key={index}
className="text-sm mb-3 font-mono border-b border-bolt-elements-borderColor pb-2 last:border-0"
>
<div className="flex items-center space-x-2 flex-wrap">
<span className={`font-bold ${getLevelColor(log.level)}`}>[{log.level.toUpperCase()}]</span>
<span className="text-bolt-elements-textSecondary">{new Date(log.timestamp).toLocaleString()}</span>
<span className="text-bolt-elements-textPrimary">{log.message}</span>
</div>
{log.details && (
<pre className="mt-2 text-xs text-bolt-elements-textSecondary overflow-x-auto">
{JSON.stringify(log.details, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@ -3,7 +3,7 @@ import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings'; import { useSettings } from '~/lib/hooks/useSettings';
export default function FeaturesTab() { export default function FeaturesTab() {
const { debug, enableDebugMode, isLocalModel, enableLocalModels } = useSettings(); const { debug, enableDebugMode, isLocalModel, enableLocalModels, eventLogs, enableEventLogs } = useSettings();
return ( return (
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4"> <div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
<div className="mb-6"> <div className="mb-6">
@ -12,6 +12,10 @@ export default function FeaturesTab() {
<span className="text-bolt-elements-textPrimary">Debug Info</span> <span className="text-bolt-elements-textPrimary">Debug Info</span>
<Switch className="ml-auto" checked={debug} onCheckedChange={enableDebugMode} /> <Switch className="ml-auto" checked={debug} onCheckedChange={enableDebugMode} />
</div> </div>
<div className="flex items-center justify-between mb-2">
<span className="text-bolt-elements-textPrimary">Event Logs</span>
<Switch className="ml-auto" checked={eventLogs} onCheckedChange={enableEventLogs} />
</div>
</div> </div>
<div className="mb-6 border-t border-bolt-elements-borderColor pt-4"> <div className="mb-6 border-t border-bolt-elements-borderColor pt-4">

View File

@ -3,6 +3,7 @@ import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings'; import { useSettings } from '~/lib/hooks/useSettings';
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings'; import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model'; import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
export default function ProvidersTab() { export default function ProvidersTab() {
const { providers, updateProviderSettings, isLocalModel } = useSettings(); const { providers, updateProviderSettings, isLocalModel } = useSettings();
@ -60,7 +61,15 @@ export default function ProvidersTab() {
<Switch <Switch
className="ml-auto" className="ml-auto"
checked={provider.settings.enabled} checked={provider.settings.enabled}
onCheckedChange={(enabled) => updateProviderSettings(provider.name, { ...provider.settings, enabled })} onCheckedChange={(enabled) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
}
}}
/> />
</div> </div>
{/* Base URL input for configurable providers */} {/* Base URL input for configurable providers */}
@ -70,9 +79,14 @@ export default function ProvidersTab() {
<input <input
type="text" type="text"
value={provider.settings.baseUrl || ''} value={provider.settings.baseUrl || ''}
onChange={(e) => onChange={(e) => {
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: e.target.value }) const newBaseUrl = e.target.value;
} updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
});
}}
placeholder={`Enter ${provider.name} base URL`} placeholder={`Enter ${provider.name} base URL`}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/> />

View File

@ -11,6 +11,7 @@ import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem'; import { HistoryItem } from './HistoryItem';
import { binDates } from './date-binning'; import { binDates } from './date-binning';
import { useSearchFilter } from '~/lib/hooks/useSearchFilter'; import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
import { ClockIcon } from '@heroicons/react/24/outline';
const menuVariants = { const menuVariants = {
closed: { closed: {
@ -35,6 +36,25 @@ const menuVariants = {
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null; type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
function CurrentDateTime() {
const [dateTime, setDateTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setDateTime(new Date());
}, 60000); // Update every minute
return () => clearInterval(timer);
}, []);
return (
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
<ClockIcon className="h-4 w-4" />
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
);
}
export const Menu = () => { export const Menu = () => {
const { duplicateCurrentChat, exportChat } = useChatHistory(); const { duplicateCurrentChat, exportChat } = useChatHistory();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -126,18 +146,17 @@ export const Menu = () => {
variants={menuVariants} variants={menuVariants}
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm" className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
> >
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div> <div className="h-[60px]" /> {/* Spacer for top margin */}
<CurrentDateTime />
<div className="flex-1 flex flex-col h-full w-full overflow-hidden"> <div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4 select-none"> <div className="p-4 select-none">
<a <a
href="/" href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme" className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
> >
<span className="inline-block i-bolt:chat scale-110" /> <span className="inline-block i-bolt:chat scale-110" />
Start new chat Start new chat
</a> </a>
</div>
<div className="pl-4 pr-4 my-2">
<div className="relative w-full"> <div className="relative w-full">
<input <input
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"

View File

@ -1,12 +1,20 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { isDebugMode, isLocalModelsEnabled, LOCAL_PROVIDERS, providersStore } from '~/lib/stores/settings'; import {
isDebugMode,
isEventLogsEnabled,
isLocalModelsEnabled,
LOCAL_PROVIDERS,
providersStore,
} from '~/lib/stores/settings';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo } from '~/types/model'; import type { IProviderSetting, ProviderInfo } from '~/types/model';
import { logStore } from '~/lib/stores/logs'; // assuming logStore is imported from this location
export function useSettings() { export function useSettings() {
const providers = useStore(providersStore); const providers = useStore(providersStore);
const debug = useStore(isDebugMode); const debug = useStore(isDebugMode);
const eventLogs = useStore(isEventLogsEnabled);
const isLocalModel = useStore(isLocalModelsEnabled); const isLocalModel = useStore(isLocalModelsEnabled);
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]); const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
@ -39,6 +47,13 @@ export function useSettings() {
isDebugMode.set(savedDebugMode === 'true'); isDebugMode.set(savedDebugMode === 'true');
} }
// load event logs from cookies
const savedEventLogs = Cookies.get('isEventLogsEnabled');
if (savedEventLogs) {
isEventLogsEnabled.set(savedEventLogs === 'true');
}
// load local models from cookies // load local models from cookies
const savedLocalModels = Cookies.get('isLocalModelsEnabled'); const savedLocalModels = Cookies.get('isLocalModelsEnabled');
@ -80,11 +95,19 @@ export function useSettings() {
const enableDebugMode = useCallback((enabled: boolean) => { const enableDebugMode = useCallback((enabled: boolean) => {
isDebugMode.set(enabled); isDebugMode.set(enabled);
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isDebugEnabled', String(enabled)); Cookies.set('isDebugEnabled', String(enabled));
}, []); }, []);
const enableEventLogs = useCallback((enabled: boolean) => {
isEventLogsEnabled.set(enabled);
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isEventLogsEnabled', String(enabled));
}, []);
const enableLocalModels = useCallback((enabled: boolean) => { const enableLocalModels = useCallback((enabled: boolean) => {
isLocalModelsEnabled.set(enabled); isLocalModelsEnabled.set(enabled);
logStore.logSystem(`Local models ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLocalModelsEnabled', String(enabled)); Cookies.set('isLocalModelsEnabled', String(enabled));
}, []); }, []);
@ -94,6 +117,8 @@ export function useSettings() {
updateProviderSettings, updateProviderSettings,
debug, debug,
enableDebugMode, enableDebugMode,
eventLogs,
enableEventLogs,
isLocalModel, isLocalModel,
enableLocalModels, enableLocalModels,
}; };

View File

@ -4,6 +4,7 @@ import { atom } from 'nanostores';
import type { Message } from 'ai'; import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { logStore } from '~/lib/stores/logs'; // Import logStore
import { import {
getMessages, getMessages,
getNextId, getNextId,
@ -43,6 +44,8 @@ export function useChatHistory() {
setReady(true); setReady(true);
if (persistenceEnabled) { if (persistenceEnabled) {
const error = new Error('Chat persistence is unavailable');
logStore.logError('Chat persistence initialization failed', error);
toast.error('Chat persistence is unavailable'); toast.error('Chat persistence is unavailable');
} }
@ -69,6 +72,7 @@ export function useChatHistory() {
setReady(true); setReady(true);
}) })
.catch((error) => { .catch((error) => {
logStore.logError('Failed to load chat messages', error);
toast.error(error.message); toast.error(error.message);
}); });
} }

149
app/lib/stores/logs.ts Normal file
View File

@ -0,0 +1,149 @@
import { atom, map } from 'nanostores';
import Cookies from 'js-cookie';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('LogStore');
export interface LogEntry {
id: string;
timestamp: string;
level: 'info' | 'warning' | 'error' | 'debug';
message: string;
details?: Record<string, any>;
category: 'system' | 'provider' | 'user' | 'error';
}
const MAX_LOGS = 1000; // Maximum number of logs to keep in memory
class LogStore {
private _logs = map<Record<string, LogEntry>>({});
showLogs = atom(false);
constructor() {
// Load saved logs from cookies on initialization
this._loadLogs();
}
private _loadLogs() {
const savedLogs = Cookies.get('eventLogs');
if (savedLogs) {
try {
const parsedLogs = JSON.parse(savedLogs);
this._logs.set(parsedLogs);
} catch (error) {
logger.error('Failed to parse logs from cookies:', error);
}
}
}
private _saveLogs() {
const currentLogs = this._logs.get();
Cookies.set('eventLogs', JSON.stringify(currentLogs));
}
private _generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private _trimLogs() {
const currentLogs = Object.entries(this._logs.get());
if (currentLogs.length > MAX_LOGS) {
const sortedLogs = currentLogs.sort(
([, a], [, b]) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
const newLogs = Object.fromEntries(sortedLogs.slice(0, MAX_LOGS));
this._logs.set(newLogs);
}
}
addLog(
message: string,
level: LogEntry['level'] = 'info',
category: LogEntry['category'] = 'system',
details?: Record<string, any>,
) {
const id = this._generateId();
const entry: LogEntry = {
id,
timestamp: new Date().toISOString(),
level,
message,
details,
category,
};
this._logs.setKey(id, entry);
this._trimLogs();
this._saveLogs();
return id;
}
// System events
logSystem(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'system', details);
}
// Provider events
logProvider(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'provider', details);
}
// User actions
logUserAction(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'user', details);
}
// Error events
logError(message: string, error?: Error | unknown, details?: Record<string, any>) {
const errorDetails = {
...(details || {}),
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: error,
};
return this.addLog(message, 'error', 'error', errorDetails);
}
// Warning events
logWarning(message: string, details?: Record<string, any>) {
return this.addLog(message, 'warning', 'system', details);
}
// Debug events
logDebug(message: string, details?: Record<string, any>) {
return this.addLog(message, 'debug', 'system', details);
}
clearLogs() {
this._logs.set({});
this._saveLogs();
}
getLogs() {
return Object.values(this._logs.get()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
getFilteredLogs(level?: LogEntry['level'], category?: LogEntry['category'], searchQuery?: string) {
return this.getLogs().filter((log) => {
const matchesLevel = !level || level === 'debug' || log.level === level;
const matchesCategory = !category || log.category === category;
const matchesSearch =
!searchQuery ||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(log.details).toLowerCase().includes(searchQuery.toLowerCase());
return matchesLevel && matchesCategory && matchesSearch;
});
}
}
export const logStore = new LogStore();

View File

@ -43,4 +43,6 @@ export const providersStore = map<ProviderSetting>(initialProviderSettings);
export const isDebugMode = atom(false); export const isDebugMode = atom(false);
export const isEventLogsEnabled = atom(false);
export const isLocalModelsEnabled = atom(true); export const isLocalModelsEnabled = atom(true);

View File

@ -1,4 +1,5 @@
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import { logStore } from './logs';
export type Theme = 'dark' | 'light'; export type Theme = 'dark' | 'light';
@ -26,10 +27,8 @@ function initStore() {
export function toggleTheme() { export function toggleTheme() {
const currentTheme = themeStore.get(); const currentTheme = themeStore.get();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
themeStore.set(newTheme); themeStore.set(newTheme);
logStore.logSystem(`Theme changed to ${newTheme} mode`);
localStorage.setItem(kTheme, newTheme); localStorage.setItem(kTheme, newTheme);
document.querySelector('html')?.setAttribute('data-theme', newTheme); document.querySelector('html')?.setAttribute('data-theme', newTheme);
} }

View File

@ -78,6 +78,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
} }
import { logStore } from './lib/stores/logs';
export default function App() { export default function App() {
return <Outlet />; const theme = useStore(themeStore);
useEffect(() => {
logStore.logSystem('Application initialized', {
theme,
platform: navigator.platform,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
});
}, []);
return (
<Layout>
<Outlet />
</Layout>
);
} }

View File

@ -2,6 +2,7 @@ import Cookies from 'js-cookie';
import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types'; import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
import type { ProviderInfo, IProviderSetting } from '~/types/model'; import type { ProviderInfo, IProviderSetting } from '~/types/model';
import { createScopedLogger } from './logger'; import { createScopedLogger } from './logger';
import { logStore } from '~/lib/stores/logs';
export const WORK_DIR_NAME = 'project'; export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`; export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
@ -373,12 +374,6 @@ const getOllamaBaseUrl = (settings?: IProviderSetting) => {
}; };
async function getOllamaModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> { async function getOllamaModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
/*
* if (typeof window === 'undefined') {
* return [];
* }
*/
try { try {
const baseUrl = getOllamaBaseUrl(settings); const baseUrl = getOllamaBaseUrl(settings);
const response = await fetch(`${baseUrl}/api/tags`); const response = await fetch(`${baseUrl}/api/tags`);
@ -391,7 +386,9 @@ async function getOllamaModels(apiKeys?: Record<string, string>, settings?: IPro
maxTokenAllowed: 8000, maxTokenAllowed: 8000,
})); }));
} catch (e: any) { } catch (e: any) {
logStore.logError('Failed to get Ollama models', e, { baseUrl: settings?.baseUrl });
logger.warn('Failed to get Ollama models: ', e.message || ''); logger.warn('Failed to get Ollama models: ', e.message || '');
return []; return [];
} }
} }
@ -480,7 +477,9 @@ async function getLMStudioModels(_apiKeys?: Record<string, string>, settings?: I
provider: 'LMStudio', provider: 'LMStudio',
})); }));
} catch (e: any) { } catch (e: any) {
logStore.logError('Failed to get LMStudio models', e, { baseUrl: settings?.baseUrl });
logger.warn('Failed to get LMStudio models: ', e.message || ''); logger.warn('Failed to get LMStudio models: ', e.message || '');
return []; return [];
} }
} }
@ -499,6 +498,7 @@ async function initializeModelList(providerSettings?: Record<string, IProviderSe
} }
} }
} catch (error: any) { } catch (error: any) {
logStore.logError('Failed to fetch API keys from cookies', error);
logger.warn(`Failed to fetch apikeys from cookies: ${error?.message}`); logger.warn(`Failed to fetch apikeys from cookies: ${error?.message}`);
} }
MODEL_LIST = [ MODEL_LIST = [