This commit is contained in:
Zacharias-Brohn
2026-01-15 14:57:30 +01:00
parent 952ee8fcab
commit 725e166f3f
+182 -10
View File
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
IconAlertCircle, IconAlertCircle,
IconBrain,
IconCode,
IconDownload, IconDownload,
IconEye,
IconPalette, IconPalette,
IconRefresh, IconRefresh,
IconRobot, IconRobot,
IconSearch, IconSearch,
IconTool,
IconTrash, IconTrash,
IconUser, IconUser,
IconX, IconX,
@@ -17,6 +21,7 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Chip,
ColorSwatch, ColorSwatch,
Divider, Divider,
Group, Group,
@@ -97,6 +102,9 @@ export function SettingsModal({
const [modelSearch, setModelSearch] = useState(''); const [modelSearch, setModelSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(modelSearch, 200); const [debouncedSearch] = useDebouncedValue(modelSearch, 200);
// Capability filter state
const [selectedCapabilities, setSelectedCapabilities] = useState<string[]>([]);
// Pagination state // Pagination state
const MODELS_PER_PAGE = 20; const MODELS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -111,13 +119,25 @@ export function SettingsModal({
[availableModels] [availableModels]
); );
// Filter models based on debounced search // Filter models based on debounced search and selected capabilities
const filteredModels = useMemo( const filteredModels = useMemo(
() => () =>
modelNames.filter((name) => modelNames.filter((name) => {
name.toLowerCase().includes(debouncedSearch.toLowerCase().trim()) // Text search filter
), const matchesSearch = name.toLowerCase().includes(debouncedSearch.toLowerCase().trim());
[modelNames, debouncedSearch] if (!matchesSearch) {
return false;
}
// Capability filter - if any capabilities selected, model must have ALL of them
if (selectedCapabilities.length > 0) {
const modelCaps = availableModels?.models[name]?.capabilities || [];
return selectedCapabilities.every((cap) => modelCaps.includes(cap));
}
return true;
}),
[modelNames, debouncedSearch, selectedCapabilities, availableModels]
); );
// Paginated models - only render what's visible // Paginated models - only render what's visible
@@ -127,10 +147,10 @@ export function SettingsModal({
return filteredModels.slice(start, start + MODELS_PER_PAGE); return filteredModels.slice(start, start + MODELS_PER_PAGE);
}, [filteredModels, currentPage]); }, [filteredModels, currentPage]);
// Reset to page 1 when search changes // Reset to page 1 when search or filter changes
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [debouncedSearch]); }, [debouncedSearch, selectedCapabilities]);
// 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) => {
@@ -312,6 +332,12 @@ export function SettingsModal({
}); });
}; };
// Get capabilities for an installed model by extracting base name
const getModelCapabilities = (fullModelName: string): string[] => {
const [baseName] = fullModelName.split(':');
return availableModels?.models[baseName]?.capabilities || [];
};
return ( return (
<Modal <Modal
opened={opened} opened={opened}
@@ -464,7 +490,9 @@ export function SettingsModal({
</Card> </Card>
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
{installedModels.map((model) => ( {installedModels.map((model) => {
const capabilities = getModelCapabilities(model.name);
return (
<Card key={model.digest} withBorder padding="sm" radius="md"> <Card key={model.digest} withBorder padding="sm" radius="md">
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
@@ -482,6 +510,30 @@ export function SettingsModal({
{model.details.quantization_level} {model.details.quantization_level}
</Badge> </Badge>
</Group> </Group>
{capabilities.length > 0 && (
<Group gap="xs" mt={4}>
{capabilities.map((cap) => (
<Badge
key={cap}
size="xs"
variant="light"
color={
cap === 'vision'
? 'violet'
: cap === 'tools'
? 'blue'
: cap === 'thinking'
? 'orange'
: cap === 'embedding'
? 'teal'
: 'gray'
}
>
{cap}
</Badge>
))}
</Group>
)}
</div> </div>
<ActionIcon <ActionIcon
color="red" color="red"
@@ -492,7 +544,8 @@ export function SettingsModal({
</ActionIcon> </ActionIcon>
</Group> </Group>
</Card> </Card>
))} );
})}
</Stack> </Stack>
)} )}
</div> </div>
@@ -519,9 +572,128 @@ export function SettingsModal({
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
value={modelSearch} value={modelSearch}
onChange={(e) => setModelSearch(e.currentTarget.value)} onChange={(e) => setModelSearch(e.currentTarget.value)}
mb="sm" mb="xs"
/> />
{/* Capability Filter */}
<Chip.Group
multiple
value={selectedCapabilities}
onChange={setSelectedCapabilities}
>
<Group
gap={0}
mb="sm"
style={{
backgroundColor: 'var(--mantine-color-default-hover)',
borderRadius: 'var(--mantine-radius-xl)',
padding: rem(4),
border: '1px solid var(--mantine-color-default-border)',
}}
>
<Chip
value="vision"
variant="filled"
radius="xl"
size="xs"
styles={{
label: {
paddingLeft: rem(10),
paddingRight: rem(10),
background: selectedCapabilities.includes('vision')
? 'linear-gradient(135deg, var(--mantine-color-violet-5), var(--mantine-color-grape-5))'
: 'transparent',
border: 'none',
color: selectedCapabilities.includes('vision')
? 'white'
: 'var(--mantine-color-dimmed)',
},
iconWrapper: { display: 'none' },
}}
>
<Group gap={4} wrap="nowrap">
<IconEye size={12} />
Vision
</Group>
</Chip>
<Chip
value="tools"
variant="filled"
radius="xl"
size="xs"
styles={{
label: {
paddingLeft: rem(10),
paddingRight: rem(10),
background: selectedCapabilities.includes('tools')
? 'linear-gradient(135deg, var(--mantine-color-blue-5), var(--mantine-color-cyan-5))'
: 'transparent',
border: 'none',
color: selectedCapabilities.includes('tools')
? 'white'
: 'var(--mantine-color-dimmed)',
},
iconWrapper: { display: 'none' },
}}
>
<Group gap={4} wrap="nowrap">
<IconTool size={12} />
Tools
</Group>
</Chip>
<Chip
value="thinking"
variant="filled"
radius="xl"
size="xs"
styles={{
label: {
paddingLeft: rem(10),
paddingRight: rem(10),
background: selectedCapabilities.includes('thinking')
? 'linear-gradient(135deg, var(--mantine-color-orange-5), var(--mantine-color-yellow-5))'
: 'transparent',
border: 'none',
color: selectedCapabilities.includes('thinking')
? 'white'
: 'var(--mantine-color-dimmed)',
},
iconWrapper: { display: 'none' },
}}
>
<Group gap={4} wrap="nowrap">
<IconBrain size={12} />
Thinking
</Group>
</Chip>
<Chip
value="embedding"
variant="filled"
radius="xl"
size="xs"
styles={{
label: {
paddingLeft: rem(10),
paddingRight: rem(10),
background: selectedCapabilities.includes('embedding')
? 'linear-gradient(135deg, var(--mantine-color-teal-5), var(--mantine-color-green-5))'
: 'transparent',
border: 'none',
color: selectedCapabilities.includes('embedding')
? 'white'
: 'var(--mantine-color-dimmed)',
},
iconWrapper: { display: 'none' },
}}
>
<Group gap={4} wrap="nowrap">
<IconCode size={12} />
Embedding
</Group>
</Chip>
</Group>
</Chip.Group>
{loadingAvailable ? ( {loadingAvailable ? (
<Group justify="center" py="md"> <Group justify="center" py="md">
<Loader size="sm" /> <Loader size="sm" />