diff --git a/components/Settings/SettingsModal.tsx b/components/Settings/SettingsModal.tsx index 289d343..e7917dd 100644 --- a/components/Settings/SettingsModal.tsx +++ b/components/Settings/SettingsModal.tsx @@ -1,10 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { IconAlertCircle, + IconChevronDown, + IconChevronRight, IconDownload, IconPalette, IconRefresh, IconRobot, + IconSearch, IconTrash, IconUser, IconX, @@ -13,14 +16,13 @@ import { ActionIcon, Alert, Badge, + Box, Button, Card, + Collapse, ColorSwatch, - Combobox, Divider, Group, - Input, - InputBase, Loader, Modal, NavLink, @@ -32,7 +34,6 @@ import { TextInput, Title, Tooltip, - useCombobox, useMantineTheme, } from '@mantine/core'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; @@ -88,41 +89,17 @@ export function SettingsModal({ const [pullingModel, setPullingModel] = useState(null); const [pullError, setPullError] = useState(''); - // Selected model and tag for downloading - const [selectedModel, setSelectedModel] = useState(''); - const [selectedTag, setSelectedTag] = useState(''); + // Expanded model card state + const [expandedModel, setExpandedModel] = useState(null); - // Combobox states + // Search state for available models 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 + // Get list of model names sorted alphabetically const modelNames = availableModels ? Object.keys(availableModels.models).sort() : []; // Filter models based on search @@ -130,15 +107,6 @@ export function SettingsModal({ 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) { @@ -211,13 +179,8 @@ export function SettingsModal({ } }; - const handlePullModel = async () => { - if (!selectedModel) { - return; - } - - // Build the full model name with tag - const fullModelName = selectedTag ? `${selectedModel}:${selectedTag}` : selectedModel; + const handlePullModel = async (modelName: string, tag: string) => { + const fullModelName = `${modelName}:${tag}`; setPullingModel(fullModelName); setPullError(''); @@ -225,8 +188,6 @@ export function SettingsModal({ try { const result = await pullModel(fullModelName); if (result.success) { - setSelectedModel(''); - setSelectedTag(''); // Force refresh installed models await fetchInstalledModels(true); } else { @@ -241,6 +202,7 @@ export function SettingsModal({ }; const handleDeleteModel = async (name: string) => { + // eslint-disable-next-line no-alert if (!confirm(`Are you sure you want to delete ${name}?`)) { return; } @@ -309,11 +271,24 @@ 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(); + const toggleModelExpand = (modelName: string) => { + setExpandedModel(expandedModel === modelName ? null : modelName); + }; + + // 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 ( @@ -321,11 +296,11 @@ export function SettingsModal({ opened={opened} onClose={close} withCloseButton={false} - size="lg" + size="xl" padding={0} radius="xl" > - + {/* Left Sidebar */} {/* Right Content */} - + - 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. - - )} + +
+ Models + + Download and manage AI models from the Ollama registry. + +
{pullError && ( } title="Error" color="red" - mt="md" withCloseButton onClose={() => setPullError('')} > @@ -546,75 +404,203 @@ export function SettingsModal({ )} - {/* Installed Models */} - - - Installed Models - - - fetchInstalledModels(true)} - loading={loadingInstalled} - > - - - - + {pullingModel && ( + } title="Downloading..." color="blue"> + Pulling {pullingModel}. This may take a while. + + )} - {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)} - > - - -
+ + + {/* 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)} + > + + +
+
+ ))} +
+ )} +
- {availableModels && ( - - Model list last updated:{' '} - {new Date(availableModels.generatedAt).toLocaleDateString()} - - )} - + + + {/* Available Models Section */} +
+ + + Available Models + + {availableModels && ( + + {modelNames.length} models + + )} + + + } + value={modelSearch} + onChange={(e) => setModelSearch(e.currentTarget.value)} + mb="sm" + /> + + {loadingAvailable ? ( + + + + ) : ( + + {filteredModels.map((modelName) => { + const tags = availableModels?.models[modelName] || []; + const isExpanded = expandedModel === modelName; + const installedTags = getInstalledTags(modelName); + + return ( + + {/* Model Header - Clickable */} + toggleModelExpand(modelName)} + > + + + {isExpanded ? ( + + ) : ( + + )} + + {modelName} + + + + + {tags.length} tags + + {installedTags.length > 0 && ( + + {installedTags.length} installed + + )} + + + + + {/* Expanded Tags List */} + + + + + {tags.map((tag) => { + const isInstalled = installedTags.includes(tag); + const isPulling = pullingModel === `${modelName}:${tag}`; + + return ( + + + {modelName}:{tag} + + {isInstalled ? ( + + Installed + + ) : ( + + )} + + ); + })} + + + + + ); + })} + + {filteredModels.length === 0 && modelSearch && ( + + No models found matching "{modelSearch}" + + )} + + )} + + {availableModels && ( + + Model list updated:{' '} + {new Date(availableModels.generatedAt).toLocaleDateString()} + + )} +
+
+
+
)} {activeTab === 'account' && (