This commit is contained in:
Zacharias-Brohn
2026-01-15 14:37:55 +01:00
parent 6981935ae0
commit 91f1993720
+76 -63
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
IconAlertCircle, IconAlertCircle,
IconChevronDown,
IconChevronRight,
IconDownload, IconDownload,
IconPalette, IconPalette,
IconRefresh, IconRefresh,
@@ -13,19 +11,19 @@ import {
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { import {
Accordion,
ActionIcon, ActionIcon,
Alert, Alert,
Badge, Badge,
Box,
Button, Button,
Card, Card,
Collapse,
ColorSwatch, ColorSwatch,
Divider, Divider,
Group, Group,
Loader, Loader,
Modal, Modal,
NavLink, NavLink,
Pagination,
PasswordInput, PasswordInput,
rem, rem,
ScrollArea, ScrollArea,
@@ -36,6 +34,7 @@ import {
Tooltip, Tooltip,
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama';
// Type for the scraped models JSON // Type for the scraped models JSON
@@ -89,24 +88,45 @@ 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('');
// Expanded model card state // Search state for available models (with debounce for performance)
const [expandedModel, setExpandedModel] = useState<string | null>(null);
// Search state for available models
const [modelSearch, setModelSearch] = useState(''); 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 // 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 sorted alphabetically // Get list of model names sorted alphabetically
const modelNames = availableModels ? Object.keys(availableModels.models).sort() : []; const modelNames = useMemo(
() => (availableModels ? Object.keys(availableModels.models).sort() : []),
// Filter models based on search [availableModels]
const filteredModels = modelNames.filter((name) =>
name.toLowerCase().includes(modelSearch.toLowerCase().trim())
); );
// 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 // 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) {
@@ -271,10 +291,6 @@ export function SettingsModal({
(color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black' (color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black'
); );
const toggleModelExpand = (modelName: string) => {
setExpandedModel(expandedModel === modelName ? null : modelName);
};
// Check if a model (base name) is already installed // Check if a model (base name) is already installed
// Returns list of installed tags for the given model name // Returns list of installed tags for the given model name
const getInstalledTags = (modelName: string): string[] => { const getInstalledTags = (modelName: string): string[] => {
@@ -486,7 +502,9 @@ export function SettingsModal({
</Text> </Text>
{availableModels && ( {availableModels && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{modelNames.length} models {filteredModels.length === modelNames.length
? `${modelNames.length} models`
: `${filteredModels.length} of ${modelNames.length} models`}
</Text> </Text>
)} )}
</Group> </Group>
@@ -504,48 +522,32 @@ export function SettingsModal({
<Loader size="sm" /> <Loader size="sm" />
</Group> </Group>
) : ( ) : (
<Stack gap="xs"> <>
{filteredModels.map((modelName) => { <Accordion variant="separated" radius="md">
const tags = availableModels?.models[modelName] || []; {paginatedModels.map((modelName) => {
const isExpanded = expandedModel === modelName; const tags = availableModels?.models[modelName] || [];
const installedTags = getInstalledTags(modelName); const installedTags = getInstalledTags(modelName);
return ( return (
<Card key={modelName} withBorder padding={0} radius="md"> <Accordion.Item key={modelName} value={modelName}>
{/* Model Header - Clickable */} <Accordion.Control>
<Box <Group justify="space-between" pr="sm">
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"> <Text fw={500} size="sm">
{modelName} {modelName}
</Text> </Text>
</Group> <Group gap="xs">
<Group gap="xs"> <Badge size="xs" variant="light" color="gray">
<Badge size="xs" variant="light" color="gray"> {tags.length} tags
{tags.length} tags
</Badge>
{installedTags.length > 0 && (
<Badge size="xs" variant="light" color="green">
{installedTags.length} installed
</Badge> </Badge>
)} {installedTags.length > 0 && (
<Badge size="xs" variant="light" color="green">
{installedTags.length} installed
</Badge>
)}
</Group>
</Group> </Group>
</Group> </Accordion.Control>
</Box> <Accordion.Panel>
{/* Expanded Tags List */}
<Collapse in={isExpanded}>
<Divider />
<Box p="sm" bg="var(--mantine-color-gray-light)">
<Stack gap="xs"> <Stack gap="xs">
{tags.map((tag) => { {tags.map((tag) => {
const isInstalled = installedTags.includes(tag); const isInstalled = installedTags.includes(tag);
@@ -577,18 +579,29 @@ export function SettingsModal({
); );
})} })}
</Stack> </Stack>
</Box> </Accordion.Panel>
</Collapse> </Accordion.Item>
</Card> );
); })}
})} </Accordion>
{filteredModels.length === 0 && modelSearch && ( {filteredModels.length === 0 && debouncedSearch && (
<Text c="dimmed" size="sm" ta="center" py="md"> <Text c="dimmed" size="sm" ta="center" py="md">
No models found matching &quot;{modelSearch}&quot; No models found matching &quot;{debouncedSearch}&quot;
</Text> </Text>
)} )}
</Stack>
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
total={totalPages}
value={currentPage}
onChange={setCurrentPage}
size="sm"
/>
</Group>
)}
</>
)} )}
{availableModels && ( {availableModels && (