import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IconAlertCircle, IconDownload, IconPalette, IconRefresh, IconRobot, IconSearch, IconTrash, IconUser, IconX, } from '@tabler/icons-react'; import { Accordion, ActionIcon, Alert, Badge, Button, Card, ColorSwatch, Divider, Group, Loader, Modal, NavLink, Pagination, PasswordInput, rem, ScrollArea, Stack, Text, TextInput, Title, Tooltip, useMantineTheme, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; // Type for the scraped models JSON interface ModelInfo { tags: string[]; capabilities: string[]; } interface OllamaModelsData { generatedAt: string; modelCount: number; models: Record; } interface User { id: string; username: string; accentColor?: string; } interface SettingsModalProps { opened: boolean; close: () => void; primaryColor: string; setPrimaryColor: (color: string) => void; } // Session-level cache for available models (survives modal close/open) let availableModelsCache: OllamaModelsData | null = null; let installedModelsCache: OllamaModel[] | null = null; export function SettingsModal({ opened, close, primaryColor, setPrimaryColor, }: SettingsModalProps) { const theme = useMantineTheme(); const [activeTab, setActiveTab] = useState<'appearance' | 'account' | 'models'>('appearance'); // Account State const [user, setUser] = useState(null); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoginMode, setIsLoginMode] = useState(true); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); // Models State const [installedModels, setInstalledModels] = useState(installedModelsCache || []); const [availableModels, setAvailableModels] = useState( availableModelsCache ); const [loadingInstalled, setLoadingInstalled] = useState(false); const [loadingAvailable, setLoadingAvailable] = useState(false); const [pullingModel, setPullingModel] = useState(null); const [pullError, setPullError] = useState(''); // Search state for available models (with debounce for performance) const [modelSearch, setModelSearch] = useState(''); const [debouncedSearch] = useDebouncedValue(modelSearch, 200); // Pagination state const MODELS_PER_PAGE = 20; const [currentPage, setCurrentPage] = useState(1); // Track if we've fetched this session const hasFetchedInstalled = useRef(false); const hasFetchedAvailable = useRef(false); // Get list of model names sorted alphabetically const modelNames = useMemo( () => (availableModels ? Object.keys(availableModels.models).sort() : []), [availableModels] ); // Filter models based on debounced search const filteredModels = useMemo( () => modelNames.filter((name) => name.toLowerCase().includes(debouncedSearch.toLowerCase().trim()) ), [modelNames, debouncedSearch] ); // Paginated models - only render what's visible const totalPages = Math.ceil(filteredModels.length / MODELS_PER_PAGE); const paginatedModels = useMemo(() => { const start = (currentPage - 1) * MODELS_PER_PAGE; return filteredModels.slice(start, start + MODELS_PER_PAGE); }, [filteredModels, currentPage]); // Reset to page 1 when search changes useEffect(() => { setCurrentPage(1); }, [debouncedSearch]); // Fetch available models from the static JSON const fetchAvailableModels = useCallback(async (force = false) => { if (!force && availableModelsCache) { setAvailableModels(availableModelsCache); return; } setLoadingAvailable(true); try { const response = await fetch('/ollama-models.json'); if (response.ok) { const data: OllamaModelsData = await response.json(); availableModelsCache = data; setAvailableModels(data); } } catch (e) { console.error('Failed to fetch available models:', e); } finally { setLoadingAvailable(false); } }, []); // Fetch installed models from Ollama const fetchInstalledModels = useCallback(async (force = false) => { if (!force && installedModelsCache) { setInstalledModels(installedModelsCache); return; } setLoadingInstalled(true); try { const list = await getInstalledModels(); installedModelsCache = list; setInstalledModels(list); } catch (e) { console.error('Failed to fetch installed models:', e); } finally { setLoadingInstalled(false); } }, []); // Check login status on mount useEffect(() => { if (opened) { fetchUser(); if (activeTab === 'models') { if (!hasFetchedInstalled.current) { fetchInstalledModels(); hasFetchedInstalled.current = true; } if (!hasFetchedAvailable.current) { fetchAvailableModels(); hasFetchedAvailable.current = true; } } } }, [opened, activeTab, fetchInstalledModels, fetchAvailableModels]); const fetchUser = async () => { try { const res = await fetch('/api/auth/me'); const data = await res.json(); if (data.user) { setUser(data.user); } else { setUser(null); } } catch (e) { console.error(e); } }; const handlePullModel = async (modelName: string, tag: string) => { const fullModelName = `${modelName}:${tag}`; setPullingModel(fullModelName); setPullError(''); try { const result = await pullModel(fullModelName); if (result.success) { // Force refresh installed models await fetchInstalledModels(true); } else { setPullError(result.message); } } catch (e) { console.error(e); setPullError('An error occurred while pulling the model'); } finally { setPullingModel(null); } }; const handleDeleteModel = async (name: string) => { // eslint-disable-next-line no-alert if (!confirm(`Are you sure you want to delete ${name}?`)) { return; } try { await deleteModel(name); // Force refresh installed models await fetchInstalledModels(true); } catch (e) { console.error(e); } }; const handleAuth = async () => { setError(''); setLoading(true); const endpoint = isLoginMode ? '/api/auth/login' : '/api/auth/register'; try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Something went wrong'); } // Refresh user state await fetchUser(); setUsername(''); setPassword(''); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); } }; const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); setUser(null); }; const handleAccentColorChange = async (color: string) => { // Update local state immediately for responsiveness setPrimaryColor(color); // If user is logged in, persist to database if (user) { try { await fetch('/api/user/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accentColor: color }), }); } catch (e) { console.error('Failed to save accent color:', e); } } }; const colors = Object.keys(theme.colors).filter( (color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black' ); // Check if a model (base name) is already installed // Returns list of installed tags for the given model name const getInstalledTags = (modelName: string): string[] => { return installedModels .filter((m) => { // Handle both "modelName:tag" and "modelName" (defaults to "latest") const [baseName] = m.name.split(':'); return baseName === modelName; }) .map((m) => { const parts = m.name.split(':'); // If no tag specified, it's "latest" return parts.length > 1 ? parts[1] : 'latest'; }); }; return ( {/* Left Sidebar */} } variant="light" color={primaryColor} onClick={() => setActiveTab('appearance')} style={{ borderRadius: 'var(--mantine-radius-lg)' }} /> } variant="light" color={primaryColor} onClick={() => setActiveTab('models')} style={{ borderRadius: 'var(--mantine-radius-lg)' }} /> } variant="light" color={primaryColor} onClick={() => setActiveTab('account')} style={{ borderRadius: 'var(--mantine-radius-lg)' }} /> {/* Right Content */} {activeTab === 'appearance' && ( <> Appearance Customize the look and feel of the application. Accent Color {colors.map((color) => ( handleAccentColorChange(color)} style={{ color: '#fff', cursor: 'pointer' }} withShadow > {primaryColor === color && } ))} )} {activeTab === 'models' && (
Models Download and manage AI models from the Ollama registry.
{pullError && ( } title="Error" color="red" withCloseButton onClose={() => setPullError('')} > {pullError} )} {pullingModel && ( } title="Downloading..." color="blue"> Pulling {pullingModel}. This may take a while. )} {/* Installed Models Section */}
Installed Models fetchInstalledModels(true)} loading={loadingInstalled} > {loadingInstalled && installedModels.length === 0 ? ( ) : installedModels.length === 0 ? ( No models installed yet. Browse available models below. ) : ( {installedModels.map((model) => (
{model.name} {(model.size / 1024 / 1024 / 1024).toFixed(2)} GB {model.details.parameter_size} {model.details.quantization_level}
handleDeleteModel(model.name)} >
))}
)}
{/* Available Models Section */}
Available Models {availableModels && ( {filteredModels.length === modelNames.length ? `${modelNames.length} models` : `${filteredModels.length} of ${modelNames.length} models`} )} } value={modelSearch} onChange={(e) => setModelSearch(e.currentTarget.value)} mb="sm" /> {loadingAvailable ? ( ) : ( <> {paginatedModels.map((modelName) => { const modelInfo = availableModels?.models[modelName]; const tags = modelInfo?.tags || []; const capabilities = modelInfo?.capabilities || []; const installedTags = getInstalledTags(modelName); return (
{modelName} {capabilities.length > 0 && ( {capabilities.map((cap) => ( {cap} ))} )}
{tags.length} tags {installedTags.length > 0 && ( {installedTags.length} installed )}
{tags.map((tag) => { const isInstalled = installedTags.includes(tag); const isPulling = pullingModel === `${modelName}:${tag}`; return ( {modelName}:{tag} {isInstalled ? ( Installed ) : ( )} ); })}
); })}
{filteredModels.length === 0 && debouncedSearch && ( No models found matching "{debouncedSearch}" )} {totalPages > 1 && ( )} )} {availableModels && ( Model list updated:{' '} {new Date(availableModels.generatedAt).toLocaleDateString()} )}
)} {activeTab === 'account' && ( <> Account Manage your account and chat history. {user ? ( Logged in as {user.username} ) : ( {error && ( } title="Error" color="red"> {error} )} setUsername(e.target.value)} /> setPassword(e.target.value)} /> setIsLoginMode(!isLoginMode)} > {isLoginMode ? 'Need an account? Register' : 'Have an account? Login'} )} )}
); }