webui: add model information dialog to router mode (#20600)
* webui: add model information dialog to router mode * webui: add "Available models" section header in model list * webui: remove nested scrollbar from chat template in model info dialog * chore: update webui build output * feat: UI improvements * refactor: Cleaner rendering + UI docs * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
parent
3c8521c4f5
commit
dddca026bf
Binary file not shown.
|
|
@ -11,7 +11,7 @@
|
||||||
iconSize?: string;
|
iconSize?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onclick: () => void;
|
onclick: (e?: MouseEvent) => void;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,38 @@
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
|
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
|
||||||
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
|
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
|
||||||
|
import type { ApiLlamaCppServerProps } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
// when set, fetch props from the child process (router mode)
|
||||||
|
modelId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable(), onOpenChange }: Props = $props();
|
let { open = $bindable(), onOpenChange, modelId = null }: Props = $props();
|
||||||
|
|
||||||
let serverProps = $derived(serverStore.props);
|
let isRouter = $derived(serverStore.isRouterMode);
|
||||||
let modelName = $derived(modelsStore.singleModelName);
|
|
||||||
|
// per-model props fetched from the child process
|
||||||
|
let routerModelProps = $state<ApiLlamaCppServerProps | null>(null);
|
||||||
|
let isLoadingRouterProps = $state(false);
|
||||||
|
|
||||||
|
// in router mode use per-model props, otherwise use global props
|
||||||
|
let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
|
||||||
|
|
||||||
|
let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
|
||||||
let models = $derived(modelOptions());
|
let models = $derived(modelOptions());
|
||||||
let isLoadingModels = $derived(modelsLoading());
|
let isLoadingModels = $derived(modelsLoading());
|
||||||
|
|
||||||
// Get the first model for single-model mode display
|
// in router mode, find the model option matching modelId
|
||||||
let firstModel = $derived(models[0] ?? null);
|
// in single mode, use the first model as before
|
||||||
|
let firstModel = $derived.by(() => {
|
||||||
|
if (isRouter && modelId) {
|
||||||
|
return models.find((m) => m.model === modelId) ?? null;
|
||||||
|
}
|
||||||
|
return models[0] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
// Get modalities from modelStore using the model ID from the first model
|
// Get modalities from modelStore using the model ID from the first model
|
||||||
let modalities = $derived.by(() => {
|
let modalities = $derived.by(() => {
|
||||||
|
|
@ -33,10 +50,31 @@
|
||||||
modelsStore.fetch();
|
modelsStore.fetch();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fetch per-model props from child process when dialog opens in router mode
|
||||||
|
$effect(() => {
|
||||||
|
if (open && isRouter && modelId) {
|
||||||
|
isLoadingRouterProps = true;
|
||||||
|
modelsStore
|
||||||
|
.fetchModelProps(modelId)
|
||||||
|
.then((props) => {
|
||||||
|
routerModelProps = props;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
routerModelProps = null;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isLoadingRouterProps = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
routerModelProps = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open {onOpenChange}>
|
<Dialog.Root bind:open {onOpenChange}>
|
||||||
<Dialog.Content class="@container z-9999 !max-w-[60rem] max-w-full">
|
<Dialog.Content class="@container z-9999 !max-h-[80dvh] !max-w-[60rem] max-w-full">
|
||||||
<style>
|
<style>
|
||||||
@container (max-width: 56rem) {
|
@container (max-width: 56rem) {
|
||||||
.resizable-text-container {
|
.resizable-text-container {
|
||||||
|
|
@ -52,7 +90,7 @@
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-6 py-4">
|
<div class="space-y-6 py-4">
|
||||||
{#if isLoadingModels}
|
{#if isLoadingModels || isLoadingRouterProps}
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-8">
|
||||||
<div class="text-sm text-muted-foreground">Loading model information...</div>
|
<div class="text-sm text-muted-foreground">Loading model information...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -212,7 +250,7 @@
|
||||||
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell class="py-10">
|
<Table.Cell class="py-10">
|
||||||
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
|
<div class="rounded-md bg-muted p-4">
|
||||||
<pre
|
<pre
|
||||||
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
|
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
|
@ -19,9 +18,11 @@
|
||||||
DialogModelInformation,
|
DialogModelInformation,
|
||||||
DropdownMenuSearchable,
|
DropdownMenuSearchable,
|
||||||
ModelId,
|
ModelId,
|
||||||
|
ModelsSelectorList,
|
||||||
ModelsSelectorOption
|
ModelsSelectorOption
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import type { ModelOption } from '$lib/types/models';
|
import type { ModelOption } from '$lib/types/models';
|
||||||
|
import { filterModelOptions, groupModelOptions, type ModelItem } from './utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -73,89 +74,13 @@
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let highlightedIndex = $state<number>(-1);
|
let highlightedIndex = $state<number>(-1);
|
||||||
|
|
||||||
let filteredOptions: ModelOption[] = $derived.by(() => {
|
let filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||||
const term = searchTerm.trim().toLowerCase();
|
|
||||||
if (!term) return options;
|
|
||||||
|
|
||||||
return options.filter(
|
let groupedFilteredOptions = $derived(
|
||||||
(option) =>
|
groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) =>
|
||||||
option.model.toLowerCase().includes(term) ||
|
modelsStore.isModelLoaded(m)
|
||||||
option.name?.toLowerCase().includes(term) ||
|
)
|
||||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
);
|
||||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let groupedFilteredOptions = $derived.by(() => {
|
|
||||||
const favIds = modelsStore.favouriteModelIds;
|
|
||||||
const result: {
|
|
||||||
orgName: string | null;
|
|
||||||
isFavouritesGroup: boolean;
|
|
||||||
isLoadedGroup: boolean;
|
|
||||||
items: { option: ModelOption; flatIndex: number }[];
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// Loaded models group (top)
|
|
||||||
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
|
|
||||||
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadedItems.length > 0) {
|
|
||||||
result.push({
|
|
||||||
orgName: null,
|
|
||||||
isFavouritesGroup: false,
|
|
||||||
isLoadedGroup: true,
|
|
||||||
items: loadedItems
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favourites group
|
|
||||||
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
|
|
||||||
const favItems: { option: ModelOption; flatIndex: number }[] = [];
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
|
|
||||||
favItems.push({ option: filteredOptions[i], flatIndex: i });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (favItems.length > 0) {
|
|
||||||
result.push({
|
|
||||||
orgName: null,
|
|
||||||
isFavouritesGroup: true,
|
|
||||||
isLoadedGroup: false,
|
|
||||||
items: favItems
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Org groups (excluding loaded and favourites)
|
|
||||||
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
const option = filteredOptions[i];
|
|
||||||
|
|
||||||
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
|
|
||||||
|
|
||||||
const orgName = option.parsedId?.orgName ?? null;
|
|
||||||
const key = orgName ?? '';
|
|
||||||
|
|
||||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
|
||||||
|
|
||||||
orgGroups.get(key)!.push({ option, flatIndex: i });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [orgName, items] of orgGroups) {
|
|
||||||
result.push({
|
|
||||||
orgName: orgName || null,
|
|
||||||
isFavouritesGroup: false,
|
|
||||||
isLoadedGroup: false,
|
|
||||||
items
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void searchTerm;
|
void searchTerm;
|
||||||
|
|
@ -164,6 +89,12 @@
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let showModelDialog = $state(false);
|
let showModelDialog = $state(false);
|
||||||
|
let infoModelId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleInfoClick(modelName: string) {
|
||||||
|
infoModelId = modelName;
|
||||||
|
showModelDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
modelsStore.fetch().catch((error) => {
|
modelsStore.fetch().catch((error) => {
|
||||||
|
|
@ -418,45 +349,39 @@
|
||||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
|
{#snippet modelOption(item: ModelItem, showOrgName: boolean)}
|
||||||
{#if group.isLoadedGroup}
|
{@const { option, flatIndex } = item}
|
||||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||||
Loaded models
|
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||||
</p>
|
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
|
||||||
{:else if group.isFavouritesGroup}
|
|
||||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
|
||||||
Favourite models
|
|
||||||
</p>
|
|
||||||
{:else if group.orgName}
|
|
||||||
<p
|
|
||||||
class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
|
||||||
>
|
|
||||||
{group.orgName}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each group.items as { option, flatIndex } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
|
<ModelsSelectorOption
|
||||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
{option}
|
||||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
{isSelected}
|
||||||
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
|
{isHighlighted}
|
||||||
|
{isFav}
|
||||||
|
{showOrgName}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onInfoClick={handleInfoClick}
|
||||||
|
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(option.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<ModelsSelectorOption
|
<ModelsSelectorList
|
||||||
{option}
|
groups={groupedFilteredOptions}
|
||||||
{isSelected}
|
{currentModel}
|
||||||
{isHighlighted}
|
{activeId}
|
||||||
{isFav}
|
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||||
showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
|
onSelect={handleSelect}
|
||||||
onSelect={handleSelect}
|
onInfoClick={handleInfoClick}
|
||||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
renderOption={modelOption}
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(option.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuSearchable>
|
</DropdownMenuSearchable>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|
@ -500,6 +425,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showModelDialog && !isRouter}
|
{#if showModelDialog}
|
||||||
<DialogModelInformation bind:open={showModelDialog} />
|
<DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
|
import { ModelsSelectorOption } from '$lib/components/app';
|
||||||
|
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: GroupedModelOptions;
|
||||||
|
currentModel: string | null;
|
||||||
|
activeId: string | null;
|
||||||
|
sectionHeaderClass?: string;
|
||||||
|
orgHeaderClass?: string;
|
||||||
|
onSelect: (modelId: string) => void;
|
||||||
|
onInfoClick: (modelName: string) => void;
|
||||||
|
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
groups,
|
||||||
|
currentModel,
|
||||||
|
activeId,
|
||||||
|
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
|
||||||
|
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||||
|
onSelect,
|
||||||
|
onInfoClick,
|
||||||
|
renderOption
|
||||||
|
}: Props = $props();
|
||||||
|
let render = $derived(renderOption ?? defaultOption);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet defaultOption(item: ModelItem, showOrgName: boolean)}
|
||||||
|
{@const { option } = item}
|
||||||
|
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||||
|
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
|
||||||
|
|
||||||
|
<ModelsSelectorOption
|
||||||
|
{option}
|
||||||
|
{isSelected}
|
||||||
|
isHighlighted={false}
|
||||||
|
{isFav}
|
||||||
|
{showOrgName}
|
||||||
|
{onSelect}
|
||||||
|
{onInfoClick}
|
||||||
|
onMouseEnter={() => {}}
|
||||||
|
onKeyDown={() => {}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if groups.loaded.length > 0}
|
||||||
|
<p class={sectionHeaderClass}>Loaded models</p>
|
||||||
|
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||||
|
{@render render(item, true)}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if groups.favourites.length > 0}
|
||||||
|
<p class={sectionHeaderClass}>Favourite models</p>
|
||||||
|
{#each groups.favourites as item (`fav-${item.option.id}`)}
|
||||||
|
{@render render(item, true)}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if groups.available.length > 0}
|
||||||
|
<p class={sectionHeaderClass}>Available models</p>
|
||||||
|
{#each groups.available as group (group.orgName)}
|
||||||
|
{#if group.orgName}
|
||||||
|
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||||
|
{/if}
|
||||||
|
{#each group.items as item (item.option.id)}
|
||||||
|
{@render render(item, false)}
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleAlert, Heart, HeartOff, Loader2, Power, PowerOff, RotateCw } from '@lucide/svelte';
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
Heart,
|
||||||
|
HeartOff,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
RotateCw
|
||||||
|
} from '@lucide/svelte';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||||
import type { ModelOption } from '$lib/types/models';
|
import type { ModelOption } from '$lib/types/models';
|
||||||
|
|
@ -15,6 +24,7 @@
|
||||||
onSelect: (modelId: string) => void;
|
onSelect: (modelId: string) => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onKeyDown: (e: KeyboardEvent) => void;
|
onKeyDown: (e: KeyboardEvent) => void;
|
||||||
|
onInfoClick?: (modelName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -25,7 +35,8 @@
|
||||||
showOrgName = false,
|
showOrgName = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onKeyDown
|
onKeyDown,
|
||||||
|
onInfoClick
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let currentRouterModels = $derived(routerModels());
|
let currentRouterModels = $derived(routerModels());
|
||||||
|
|
@ -63,11 +74,11 @@
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-2.5">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none flex w-4 items-center justify-center pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
|
class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{#if isFav}
|
{#if isFav}
|
||||||
|
|
@ -87,7 +98,19 @@
|
||||||
onclick={() => modelsStore.toggleFavourite(option.model)}
|
onclick={() => modelsStore.toggleFavourite(option.model)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- info button: only shown when model is loaded and callback is provided -->
|
||||||
|
{#if isLoaded && onInfoClick}
|
||||||
|
<ActionIcon
|
||||||
|
iconSize="h-2.5 w-2.5"
|
||||||
|
icon={Info}
|
||||||
|
tooltip="Model information"
|
||||||
|
class="h-3 w-3 hover:text-foreground"
|
||||||
|
onclick={() => onInfoClick(option.model)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
{:else if isFailed}
|
{:else if isFailed}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
|
|
@ -15,11 +14,12 @@
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
import {
|
import {
|
||||||
DialogModelInformation,
|
DialogModelInformation,
|
||||||
|
ModelsSelectorList,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
TruncatedText,
|
TruncatedText
|
||||||
ModelsSelectorOption
|
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import type { ModelOption } from '$lib/types/models';
|
import type { ModelOption } from '$lib/types/models';
|
||||||
|
import { filterModelOptions, groupModelOptions } from './utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -73,85 +73,22 @@
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
|
|
||||||
let filteredOptions: ModelOption[] = $derived.by(() => {
|
let filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||||
const term = searchTerm.trim().toLowerCase();
|
|
||||||
if (!term) return options;
|
|
||||||
|
|
||||||
return options.filter(
|
let groupedFilteredOptions = $derived(
|
||||||
(option) =>
|
groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) =>
|
||||||
option.model.toLowerCase().includes(term) ||
|
modelsStore.isModelLoaded(m)
|
||||||
option.name?.toLowerCase().includes(term) ||
|
)
|
||||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
);
|
||||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let groupedFilteredOptions = $derived.by(() => {
|
|
||||||
const favIds = modelsStore.favouriteModelIds;
|
|
||||||
const result: {
|
|
||||||
orgName: string | null;
|
|
||||||
isFavouritesGroup: boolean;
|
|
||||||
isLoadedGroup: boolean;
|
|
||||||
items: { option: ModelOption; flatIndex: number }[];
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// Loaded models group (top)
|
|
||||||
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
|
|
||||||
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (loadedItems.length > 0) {
|
|
||||||
result.push({
|
|
||||||
orgName: null,
|
|
||||||
isFavouritesGroup: false,
|
|
||||||
isLoadedGroup: true,
|
|
||||||
items: loadedItems
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favourites group
|
|
||||||
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
|
|
||||||
const favItems: { option: ModelOption; flatIndex: number }[] = [];
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
|
|
||||||
favItems.push({ option: filteredOptions[i], flatIndex: i });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (favItems.length > 0) {
|
|
||||||
result.push({
|
|
||||||
orgName: null,
|
|
||||||
isFavouritesGroup: true,
|
|
||||||
isLoadedGroup: false,
|
|
||||||
items: favItems
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Org groups (excluding loaded and favourites)
|
|
||||||
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
|
|
||||||
for (let i = 0; i < filteredOptions.length; i++) {
|
|
||||||
const option = filteredOptions[i];
|
|
||||||
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
|
|
||||||
const orgName = option.parsedId?.orgName ?? null;
|
|
||||||
const key = orgName ?? '';
|
|
||||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
|
||||||
orgGroups.get(key)!.push({ option, flatIndex: i });
|
|
||||||
}
|
|
||||||
for (const [orgName, items] of orgGroups) {
|
|
||||||
result.push({
|
|
||||||
orgName: orgName || null,
|
|
||||||
isFavouritesGroup: false,
|
|
||||||
isLoadedGroup: false,
|
|
||||||
items
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
let sheetOpen = $state(false);
|
let sheetOpen = $state(false);
|
||||||
let showModelDialog = $state(false);
|
let showModelDialog = $state(false);
|
||||||
|
let infoModelId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleInfoClick(modelName: string) {
|
||||||
|
infoModelId = modelName;
|
||||||
|
showModelDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
modelsStore.fetch().catch((error) => {
|
modelsStore.fetch().catch((error) => {
|
||||||
|
|
@ -339,38 +276,15 @@
|
||||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
|
<ModelsSelectorList
|
||||||
{#if group.isLoadedGroup}
|
groups={groupedFilteredOptions}
|
||||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
{currentModel}
|
||||||
Loaded models
|
{activeId}
|
||||||
</p>
|
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||||
{:else if group.isFavouritesGroup}
|
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
onSelect={handleSelect}
|
||||||
Favourite models
|
onInfoClick={handleInfoClick}
|
||||||
</p>
|
/>
|
||||||
{:else if group.orgName}
|
|
||||||
<p
|
|
||||||
class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
|
||||||
>
|
|
||||||
{group.orgName}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each group.items as { option } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
|
|
||||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
|
||||||
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
|
|
||||||
<ModelsSelectorOption
|
|
||||||
{option}
|
|
||||||
{isSelected}
|
|
||||||
isHighlighted={false}
|
|
||||||
{isFav}
|
|
||||||
showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onMouseEnter={() => {}}
|
|
||||||
onKeyDown={() => {}}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
|
|
@ -403,6 +317,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showModelDialog && !isRouter}
|
{#if showModelDialog}
|
||||||
<DialogModelInformation bind:open={showModelDialog} />
|
<DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,27 @@
|
||||||
*/
|
*/
|
||||||
export { default as ModelsSelector } from './ModelsSelector.svelte';
|
export { default as ModelsSelector } from './ModelsSelector.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **ModelsSelectorList** - Grouped model options list
|
||||||
|
*
|
||||||
|
* Renders grouped model options (loaded, favourites, available) with section
|
||||||
|
* headers and org subgroups. Shared between ModelsSelector and ModelsSelectorSheet
|
||||||
|
* to avoid template duplication.
|
||||||
|
*
|
||||||
|
* Accepts an optional `renderOption` snippet to customize how each option is
|
||||||
|
* rendered (e.g., to add keyboard navigation or highlighting).
|
||||||
|
*/
|
||||||
|
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **ModelsSelectorOption** - Single model option row
|
||||||
|
*
|
||||||
|
* Renders a single model option with selection state, favourite toggle,
|
||||||
|
* load/unload actions, status indicators, and an info button.
|
||||||
|
* Used inside ModelsSelectorList or directly in custom render snippets.
|
||||||
|
*/
|
||||||
|
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||||
*
|
*
|
||||||
|
|
@ -80,5 +101,12 @@ export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export { default as ModelBadge } from './ModelBadge.svelte';
|
export { default as ModelBadge } from './ModelBadge.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **ModelId** - Parsed model identifier display
|
||||||
|
*
|
||||||
|
* Displays a model ID with optional org name, parameter badges, quantization,
|
||||||
|
* aliases, and tags. Supports raw mode to show the unprocessed model name.
|
||||||
|
* Respects the user's `showRawModelNames` setting.
|
||||||
|
*/
|
||||||
export { default as ModelId } from './ModelId.svelte';
|
export { default as ModelId } from './ModelId.svelte';
|
||||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import type { ModelOption } from '$lib/types/models';
|
||||||
|
|
||||||
|
export interface ModelItem {
|
||||||
|
option: ModelOption;
|
||||||
|
flatIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgGroup {
|
||||||
|
orgName: string | null;
|
||||||
|
items: ModelItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedModelOptions {
|
||||||
|
loaded: ModelItem[];
|
||||||
|
favourites: ModelItem[];
|
||||||
|
available: OrgGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
if (!term) return options;
|
||||||
|
|
||||||
|
return options.filter(
|
||||||
|
(option) =>
|
||||||
|
option.model.toLowerCase().includes(term) ||
|
||||||
|
option.name?.toLowerCase().includes(term) ||
|
||||||
|
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||||
|
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupModelOptions(
|
||||||
|
filteredOptions: ModelOption[],
|
||||||
|
favouriteIds: Set<string>,
|
||||||
|
isModelLoaded: (model: string) => boolean
|
||||||
|
): GroupedModelOptions {
|
||||||
|
// Loaded models
|
||||||
|
const loaded: ModelItem[] = [];
|
||||||
|
for (let i = 0; i < filteredOptions.length; i++) {
|
||||||
|
if (isModelLoaded(filteredOptions[i].model)) {
|
||||||
|
loaded.push({ option: filteredOptions[i], flatIndex: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favourites (excluding loaded)
|
||||||
|
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
|
||||||
|
const favourites: ModelItem[] = [];
|
||||||
|
for (let i = 0; i < filteredOptions.length; i++) {
|
||||||
|
if (
|
||||||
|
favouriteIds.has(filteredOptions[i].model) &&
|
||||||
|
!loadedModelIds.has(filteredOptions[i].model)
|
||||||
|
) {
|
||||||
|
favourites.push({ option: filteredOptions[i], flatIndex: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available models grouped by org (excluding loaded and favourites)
|
||||||
|
const available: OrgGroup[] = [];
|
||||||
|
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||||
|
for (let i = 0; i < filteredOptions.length; i++) {
|
||||||
|
const option = filteredOptions[i];
|
||||||
|
if (loadedModelIds.has(option.model) || favouriteIds.has(option.model)) continue;
|
||||||
|
|
||||||
|
const key = option.parsedId?.orgName ?? '';
|
||||||
|
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||||
|
orgGroups.get(key)!.push({ option, flatIndex: i });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [orgName, items] of orgGroups) {
|
||||||
|
available.push({ orgName: orgName || null, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loaded, favourites, available };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue