This commit is contained in:
Zacharias-Brohn
2026-01-15 14:31:37 +01:00
parent 56b64c30e8
commit 6981935ae0
+175 -189
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
IconAlertCircle, IconAlertCircle,
IconChevronDown,
IconChevronRight,
IconDownload, IconDownload,
IconPalette, IconPalette,
IconRefresh, IconRefresh,
IconRobot, IconRobot,
IconSearch,
IconTrash, IconTrash,
IconUser, IconUser,
IconX, IconX,
@@ -13,14 +16,13 @@ import {
ActionIcon, ActionIcon,
Alert, Alert,
Badge, Badge,
Box,
Button, Button,
Card, Card,
Collapse,
ColorSwatch, ColorSwatch,
Combobox,
Divider, Divider,
Group, Group,
Input,
InputBase,
Loader, Loader,
Modal, Modal,
NavLink, NavLink,
@@ -32,7 +34,6 @@ import {
TextInput, TextInput,
Title, Title,
Tooltip, Tooltip,
useCombobox,
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama';
@@ -88,41 +89,17 @@ export function SettingsModal({
const [pullingModel, setPullingModel] = useState<string | null>(null); const [pullingModel, setPullingModel] = useState<string | null>(null);
const [pullError, setPullError] = useState(''); const [pullError, setPullError] = useState('');
// Selected model and tag for downloading // Expanded model card state
const [selectedModel, setSelectedModel] = useState<string>(''); const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [selectedTag, setSelectedTag] = useState<string>('');
// Combobox states // Search state for available models
const [modelSearch, setModelSearch] = useState(''); 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 // Track if we've fetched this session
const hasFetchedInstalled = useRef(false); const hasFetchedInstalled = useRef(false);
const hasFetchedAvailable = 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() : []; const modelNames = availableModels ? Object.keys(availableModels.models).sort() : [];
// Filter models based on search // Filter models based on search
@@ -130,15 +107,6 @@ export function SettingsModal({
name.toLowerCase().includes(modelSearch.toLowerCase().trim()) 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 // Fetch available models from the static JSON
const fetchAvailableModels = useCallback(async (force = false) => { const fetchAvailableModels = useCallback(async (force = false) => {
if (!force && availableModelsCache) { if (!force && availableModelsCache) {
@@ -211,13 +179,8 @@ export function SettingsModal({
} }
}; };
const handlePullModel = async () => { const handlePullModel = async (modelName: string, tag: string) => {
if (!selectedModel) { const fullModelName = `${modelName}:${tag}`;
return;
}
// Build the full model name with tag
const fullModelName = selectedTag ? `${selectedModel}:${selectedTag}` : selectedModel;
setPullingModel(fullModelName); setPullingModel(fullModelName);
setPullError(''); setPullError('');
@@ -225,8 +188,6 @@ export function SettingsModal({
try { try {
const result = await pullModel(fullModelName); const result = await pullModel(fullModelName);
if (result.success) { if (result.success) {
setSelectedModel('');
setSelectedTag('');
// Force refresh installed models // Force refresh installed models
await fetchInstalledModels(true); await fetchInstalledModels(true);
} else { } else {
@@ -241,6 +202,7 @@ export function SettingsModal({
}; };
const handleDeleteModel = async (name: string) => { const handleDeleteModel = async (name: string) => {
// eslint-disable-next-line no-alert
if (!confirm(`Are you sure you want to delete ${name}?`)) { if (!confirm(`Are you sure you want to delete ${name}?`)) {
return; return;
} }
@@ -309,11 +271,24 @@ export function SettingsModal({
(color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black' (color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black'
); );
// When model selection changes, reset tag const toggleModelExpand = (modelName: string) => {
const handleModelSelect = (model: string) => { setExpandedModel(expandedModel === modelName ? null : modelName);
setSelectedModel(model); };
setSelectedTag('');
modelCombobox.closeDropdown(); // 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 ( return (
@@ -321,11 +296,11 @@ export function SettingsModal({
opened={opened} opened={opened}
onClose={close} onClose={close}
withCloseButton={false} withCloseButton={false}
size="lg" size="xl"
padding={0} padding={0}
radius="xl" radius="xl"
> >
<Group align="stretch" gap={0} style={{ minHeight: 400, overflow: 'hidden' }}> <Group align="stretch" gap={0} style={{ minHeight: 600, overflow: 'hidden' }}>
{/* Left Sidebar */} {/* Left Sidebar */}
<Stack <Stack
gap="xs" gap="xs"
@@ -366,7 +341,7 @@ export function SettingsModal({
</Stack> </Stack>
{/* Right Content */} {/* Right Content */}
<Stack p="xl" style={{ flex: 1, position: 'relative' }}> <Stack p="xl" style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
<ActionIcon <ActionIcon
onClick={close} onClick={close}
variant="subtle" variant="subtle"
@@ -409,136 +384,19 @@ export function SettingsModal({
)} )}
{activeTab === 'models' && ( {activeTab === 'models' && (
<> <Stack gap="md" style={{ flex: 1, overflow: 'hidden' }}>
<div>
<Title order={4}>Models</Title> <Title order={4}>Models</Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Download and manage AI models from the Ollama registry. Download and manage AI models from the Ollama registry.
</Text> </Text>
<Divider my="sm" /> </div>
{/* Model Selection */}
<Text size="sm" fw={500} mb="xs">
Download New Model
</Text>
<Group align="flex-end" gap="sm">
{/* Model Name Dropdown */}
<Combobox
store={modelCombobox}
withinPortal={false}
onOptionSubmit={handleModelSelect}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={loadingAvailable ? <Loader size={14} /> : <Combobox.Chevron />}
onClick={() => modelCombobox.toggleDropdown()}
rightSectionPointerEvents="none"
label="Model"
style={{ minWidth: 180 }}
>
{selectedModel || <Input.Placeholder>Select model</Input.Placeholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={modelSearch}
onChange={(event) => setModelSearch(event.currentTarget.value)}
placeholder="Search models..."
/>
<Combobox.Options>
<ScrollArea.Autosize type="scroll" mah={200}>
{filteredModels.length > 0 ? (
filteredModels.map((name) => (
<Combobox.Option value={name} key={name}>
{name}
</Combobox.Option>
))
) : (
<Combobox.Empty>No models found</Combobox.Empty>
)}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Tag/Quantization Dropdown */}
<Combobox
store={tagCombobox}
withinPortal={false}
onOptionSubmit={(val) => {
setSelectedTag(val);
tagCombobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
onClick={() => tagCombobox.toggleDropdown()}
rightSectionPointerEvents="none"
label="Tag"
disabled={!selectedModel}
style={{ minWidth: 180 }}
>
{selectedTag || (
<Input.Placeholder>
{selectedModel ? 'Select tag (optional)' : 'Select model first'}
</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={tagSearch}
onChange={(event) => setTagSearch(event.currentTarget.value)}
placeholder="Search tags..."
/>
<Combobox.Options>
<ScrollArea.Autosize type="scroll" mah={200}>
{filteredTags.length > 0 ? (
filteredTags.map((tag) => (
<Combobox.Option value={tag} key={tag}>
{tag}
</Combobox.Option>
))
) : (
<Combobox.Empty>No tags found</Combobox.Empty>
)}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Button
onClick={handlePullModel}
loading={!!pullingModel}
disabled={!selectedModel}
leftSection={<IconDownload size={16} />}
color={primaryColor}
>
Pull
</Button>
</Group>
{pullingModel && (
<Alert icon={<Loader size={16} />} title="Downloading..." color="blue" mt="md">
Pulling {pullingModel}. This may take a while depending on your connection.
</Alert>
)}
{pullError && ( {pullError && (
<Alert <Alert
icon={<IconAlertCircle size={16} />} icon={<IconAlertCircle size={16} />}
title="Error" title="Error"
color="red" color="red"
mt="md"
withCloseButton withCloseButton
onClose={() => setPullError('')} onClose={() => setPullError('')}
> >
@@ -546,9 +404,18 @@ export function SettingsModal({
</Alert> </Alert>
)} )}
{/* Installed Models */} {pullingModel && (
<Group justify="space-between" mt="xl" mb="xs"> <Alert icon={<Loader size={16} />} title="Downloading..." color="blue">
<Text size="sm" fw={500}> Pulling {pullingModel}. This may take a while.
</Alert>
)}
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
<Stack gap="lg">
{/* Installed Models Section */}
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Installed Models Installed Models
</Text> </Text>
<Tooltip label="Refresh"> <Tooltip label="Refresh">
@@ -565,15 +432,16 @@ export function SettingsModal({
</Group> </Group>
{loadingInstalled && installedModels.length === 0 ? ( {loadingInstalled && installedModels.length === 0 ? (
<Group justify="center" py="xl"> <Group justify="center" py="md">
<Loader size="sm" /> <Loader size="sm" />
</Group> </Group>
) : installedModels.length === 0 ? ( ) : installedModels.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="xl"> <Card withBorder padding="md" radius="md">
No models installed. Pull one from above! <Text c="dimmed" size="sm" ta="center">
No models installed yet. Browse available models below.
</Text> </Text>
</Card>
) : ( ) : (
<ScrollArea h={200} offsetScrollbars>
<Stack gap="xs"> <Stack gap="xs">
{installedModels.map((model) => ( {installedModels.map((model) => (
<Card key={model.digest} withBorder padding="sm" radius="md"> <Card key={model.digest} withBorder padding="sm" radius="md">
@@ -582,7 +450,7 @@ export function SettingsModal({
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{model.name} {model.name}
</Text> </Text>
<Group gap="xs"> <Group gap="xs" mt={4}>
<Badge size="xs" variant="light" color="gray"> <Badge size="xs" variant="light" color="gray">
{(model.size / 1024 / 1024 / 1024).toFixed(2)} GB {(model.size / 1024 / 1024 / 1024).toFixed(2)} GB
</Badge> </Badge>
@@ -605,16 +473,134 @@ export function SettingsModal({
</Card> </Card>
))} ))}
</Stack> </Stack>
</ScrollArea> )}
</div>
<Divider />
{/* Available Models Section */}
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Available Models
</Text>
{availableModels && (
<Text size="xs" c="dimmed">
{modelNames.length} models
</Text>
)}
</Group>
<TextInput
placeholder="Search models..."
leftSection={<IconSearch size={16} />}
value={modelSearch}
onChange={(e) => setModelSearch(e.currentTarget.value)}
mb="sm"
/>
{loadingAvailable ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : (
<Stack gap="xs">
{filteredModels.map((modelName) => {
const tags = availableModels?.models[modelName] || [];
const isExpanded = expandedModel === modelName;
const installedTags = getInstalledTags(modelName);
return (
<Card key={modelName} withBorder padding={0} radius="md">
{/* Model Header - Clickable */}
<Box
p="sm"
style={{ cursor: 'pointer' }}
onClick={() => toggleModelExpand(modelName)}
>
<Group justify="space-between">
<Group gap="sm">
{isExpanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
<Text fw={500} size="sm">
{modelName}
</Text>
</Group>
<Group gap="xs">
<Badge size="xs" variant="light" color="gray">
{tags.length} tags
</Badge>
{installedTags.length > 0 && (
<Badge size="xs" variant="light" color="green">
{installedTags.length} installed
</Badge>
)}
</Group>
</Group>
</Box>
{/* Expanded Tags List */}
<Collapse in={isExpanded}>
<Divider />
<Box p="sm" bg="var(--mantine-color-gray-light)">
<Stack gap="xs">
{tags.map((tag) => {
const isInstalled = installedTags.includes(tag);
const isPulling = pullingModel === `${modelName}:${tag}`;
return (
<Group key={tag} justify="space-between">
<Text size="sm" c={isInstalled ? 'dimmed' : undefined}>
{modelName}:{tag}
</Text>
{isInstalled ? (
<Badge size="xs" variant="light" color="green">
Installed
</Badge>
) : (
<Button
size="xs"
variant="light"
color={primaryColor}
leftSection={<IconDownload size={14} />}
onClick={() => handlePullModel(modelName, tag)}
loading={isPulling}
disabled={!!pullingModel}
>
Download
</Button>
)}
</Group>
);
})}
</Stack>
</Box>
</Collapse>
</Card>
);
})}
{filteredModels.length === 0 && modelSearch && (
<Text c="dimmed" size="sm" ta="center" py="md">
No models found matching &quot;{modelSearch}&quot;
</Text>
)}
</Stack>
)} )}
{availableModels && ( {availableModels && (
<Text size="xs" c="dimmed" mt="md"> <Text size="xs" c="dimmed" mt="md">
Model list last updated:{' '} Model list updated:{' '}
{new Date(availableModels.generatedAt).toLocaleDateString()} {new Date(availableModels.generatedAt).toLocaleDateString()}
</Text> </Text>
)} )}
</> </div>
</Stack>
</ScrollArea>
</Stack>
)} )}
{activeTab === 'account' && ( {activeTab === 'account' && (