diff --git a/components/Settings/SettingsModal.tsx b/components/Settings/SettingsModal.tsx index 384bcd9..289d343 100644 --- a/components/Settings/SettingsModal.tsx +++ b/components/Settings/SettingsModal.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { IconAlertCircle, IconDownload, IconPalette, + IconRefresh, IconRobot, IconTrash, IconUser, @@ -30,27 +31,18 @@ import { Text, TextInput, Title, + Tooltip, useCombobox, useMantineTheme, } from '@mantine/core'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; -/* - * Popular models list - reserved for future autocomplete feature - * const POPULAR_MODELS = [ - * 'llama3.2', - * 'llama3.1', - * 'mistral', - * 'gemma2', - * 'qwen2.5', - * 'phi3.5', - * 'neural-chat', - * 'starling-lm', - * 'codellama', - * 'deepseek-coder', - * 'llava', - * ]; - */ +// Type for the scraped models JSON +interface OllamaModelsData { + generatedAt: string; + modelCount: number; + models: Record; +} interface User { id: string; @@ -65,6 +57,10 @@ interface SettingsModalProps { 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, @@ -83,42 +79,123 @@ export function SettingsModal({ const [loading, setLoading] = useState(false); // Models State - const [models, setModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(false); + 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 [newModelName, setNewModelName] = useState(''); + const [pullError, setPullError] = useState(''); - // Combobox State - const [search, setSearch] = useState(''); - const combobox = useCombobox({ + // 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: () => { - combobox.resetSelectedOption(); - combobox.focusTarget(); - setSearch(''); + modelCombobox.resetSelectedOption(); + modelCombobox.focusTarget(); + setModelSearch(''); }, onDropdownOpen: () => { - combobox.focusSearchInput(); + modelCombobox.focusSearchInput(); }, }); - // Filter installed models based on search - const options = models - .filter((item) => item.name.toLowerCase().includes(search.toLowerCase().trim())) - .map((item) => ( - - {item.name} - - )); + 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') { - fetchModels(); + if (!hasFetchedInstalled.current) { + fetchInstalledModels(); + hasFetchedInstalled.current = true; + } + if (!hasFetchedAvailable.current) { + fetchAvailableModels(); + hasFetchedAvailable.current = true; + } } } - }, [opened, activeTab]); + }, [opened, activeTab, fetchInstalledModels, fetchAvailableModels]); const fetchUser = async () => { try { @@ -130,55 +207,48 @@ export function SettingsModal({ setUser(null); } } catch (e) { - // eslint-disable-next-line no-console console.error(e); } }; - const fetchModels = async () => { - setLoadingModels(true); - try { - const list = await getInstalledModels(); - setModels(list); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } finally { - setLoadingModels(false); - } - }; - const handlePullModel = async () => { - if (!newModelName) { + if (!selectedModel) { return; } - setPullingModel(newModelName); + + // Build the full model name with tag + const fullModelName = selectedTag ? `${selectedModel}:${selectedTag}` : selectedModel; + + setPullingModel(fullModelName); + setPullError(''); + try { - const result = await pullModel(newModelName); + const result = await pullModel(fullModelName); if (result.success) { - setNewModelName(''); - await fetchModels(); + setSelectedModel(''); + setSelectedTag(''); + // Force refresh installed models + await fetchInstalledModels(true); } else { - setError(result.message); + setPullError(result.message); } } catch (e) { - // eslint-disable-next-line no-console 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); - await fetchModels(); + // Force refresh installed models + await fetchInstalledModels(true); } catch (e) { - // eslint-disable-next-line no-console console.error(e); } }; @@ -230,7 +300,6 @@ export function SettingsModal({ body: JSON.stringify({ accentColor: color }), }); } catch (e) { - // eslint-disable-next-line no-console console.error('Failed to save accent color:', e); } } @@ -240,6 +309,13 @@ export function SettingsModal({ (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 ( Models - Manage your local AI models via Ollama. + 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 */} + { - setNewModelName(val); - combobox.closeDropdown(); - // Optional: trigger pull immediately or let user click button? - // User code sample sets value. I'll set newModelName. + setSelectedTag(val); + tagCombobox.closeDropdown(); }} > @@ -357,33 +480,38 @@ export function SettingsModal({ type="button" pointer rightSection={} - onClick={() => combobox.toggleDropdown()} + onClick={() => tagCombobox.toggleDropdown()} rightSectionPointerEvents="none" - label="Download Model" - description="Select an installed model to update, or type a new model name (e.g. llama3)" - style={{ flex: 1 }} + label="Tag" + disabled={!selectedModel} + style={{ minWidth: 180 }} > - {newModelName || ( - Pick or type model name + {selectedTag || ( + + {selectedModel ? 'Select tag (optional)' : 'Select model first'} + )} { - setSearch(event.currentTarget.value); - setNewModelName(event.currentTarget.value); // Allow typing new names - }} - placeholder="Search installed models or type new one" + value={tagSearch} + onChange={(event) => setTagSearch(event.currentTarget.value)} + placeholder="Search tags..." /> - {options.length > 0 ? ( - options - ) : ( - No matching installed models - )} + + {filteredTags.length > 0 ? ( + filteredTags.map((tag) => ( + + {tag} + + )) + ) : ( + No tags found + )} + @@ -391,6 +519,7 @@ export function SettingsModal({