import { useCallback, useEffect, useRef, useState } from 'react'; import { IconAlertCircle, IconDownload, IconPalette, IconRefresh, IconRobot, IconTrash, IconUser, IconX, } from '@tabler/icons-react'; import { ActionIcon, Alert, Badge, Button, Card, ColorSwatch, Combobox, Divider, Group, Input, InputBase, Loader, Modal, NavLink, PasswordInput, rem, ScrollArea, Stack, Text, TextInput, Title, Tooltip, useCombobox, useMantineTheme, } from '@mantine/core'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; // Type for the scraped models JSON 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(''); // Selected model and tag for downloading const [selectedModel, setSelectedModel] = useState(''); const [selectedTag, setSelectedTag] = useState(''); // Combobox states const [modelSearch, setModelSearch] = useState(''); const [tagSearch, setTagSearch] = useState(''); const modelCombobox = useCombobox({ onDropdownClose: () => { modelCombobox.resetSelectedOption(); modelCombobox.focusTarget(); setModelSearch(''); }, onDropdownOpen: () => { modelCombobox.focusSearchInput(); }, }); const tagCombobox = useCombobox({ onDropdownClose: () => { tagCombobox.resetSelectedOption(); tagCombobox.focusTarget(); setTagSearch(''); }, onDropdownOpen: () => { tagCombobox.focusSearchInput(); }, }); // Track if we've fetched this session const hasFetchedInstalled = useRef(false); const hasFetchedAvailable = useRef(false); // Get list of model names for the dropdown const modelNames = availableModels ? Object.keys(availableModels.models).sort() : []; // Filter models based on search const filteredModels = modelNames.filter((name) => name.toLowerCase().includes(modelSearch.toLowerCase().trim()) ); // Get tags for the selected model const availableTags = selectedModel && availableModels ? availableModels.models[selectedModel] || [] : []; // Filter tags based on search const filteredTags = availableTags.filter((tag) => tag.toLowerCase().includes(tagSearch.toLowerCase().trim()) ); // 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 () => { if (!selectedModel) { return; } // Build the full model name with tag const fullModelName = selectedTag ? `${selectedModel}:${selectedTag}` : selectedModel; setPullingModel(fullModelName); setPullError(''); try { const result = await pullModel(fullModelName); if (result.success) { setSelectedModel(''); setSelectedTag(''); // 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) => { 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' ); // When model selection changes, reset tag const handleModelSelect = (model: string) => { setSelectedModel(model); setSelectedTag(''); modelCombobox.closeDropdown(); }; 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. {/* Model Selection */} Download New Model {/* Model Name Dropdown */} : } onClick={() => modelCombobox.toggleDropdown()} rightSectionPointerEvents="none" label="Model" style={{ minWidth: 180 }} > {selectedModel || Select model} setModelSearch(event.currentTarget.value)} placeholder="Search models..." /> {filteredModels.length > 0 ? ( filteredModels.map((name) => ( {name} )) ) : ( No models found )} {/* Tag/Quantization Dropdown */} { setSelectedTag(val); tagCombobox.closeDropdown(); }} > } onClick={() => tagCombobox.toggleDropdown()} rightSectionPointerEvents="none" label="Tag" disabled={!selectedModel} style={{ minWidth: 180 }} > {selectedTag || ( {selectedModel ? 'Select tag (optional)' : 'Select model first'} )} setTagSearch(event.currentTarget.value)} placeholder="Search tags..." /> {filteredTags.length > 0 ? ( filteredTags.map((tag) => ( {tag} )) ) : ( No tags found )} {pullingModel && ( } title="Downloading..." color="blue" mt="md"> Pulling {pullingModel}. This may take a while depending on your connection. )} {pullError && ( } title="Error" color="red" mt="md" withCloseButton onClose={() => setPullError('')} > {pullError} )} {/* Installed Models */} Installed Models fetchInstalledModels(true)} loading={loadingInstalled} > {loadingInstalled && installedModels.length === 0 ? ( ) : installedModels.length === 0 ? ( No models installed. Pull one from above! ) : ( {installedModels.map((model) => (
{model.name} {(model.size / 1024 / 1024 / 1024).toFixed(2)} GB {model.details.parameter_size} {model.details.quantization_level}
handleDeleteModel(model.name)} >
))}
)} {availableModels && ( Model list last 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'} )} )}
); }