]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: add model information dialog to router mode (#20600)
authorPascal <redacted>
Mon, 16 Mar 2026 14:38:11 +0000 (15:38 +0100)
committerGitHub <redacted>
Mon, 16 Mar 2026 14:38:11 +0000 (15:38 +0100)
* 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 <redacted>
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte
tools/server/webui/src/lib/components/app/models/index.ts
tools/server/webui/src/lib/components/app/models/utils.ts [new file with mode: 0644]

index 9cff8a01267bd945e034dffa76bfb7b881769cf9..6dcd04cbf3c227f8f3a16c2ae3e7febd7ac9e6f6 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index c676e224a7221a63432070517aa3c5026866750d..1d2dd3c1d9d44f37e301750898a7cff2aa0c3119 100644 (file)
@@ -11,7 +11,7 @@
                iconSize?: string;
                class?: string;
                disabled?: boolean;
-               onclick: () => void;
+               onclick: (e?: MouseEvent) => void;
                'aria-label'?: string;
        }
 
index eac83f234ddd73d775ab5aa874357b7c48753e3c..3a1db5c77d3ba90cf9d92f20a4e6d4eeea8316e6 100644 (file)
@@ -5,21 +5,38 @@
        import { serverStore } from '$lib/stores/server.svelte';
        import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
        import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
+       import type { ApiLlamaCppServerProps } from '$lib/types';
 
        interface Props {
                open?: boolean;
                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 modelName = $derived(modelsStore.singleModelName);
+       let isRouter = $derived(serverStore.isRouterMode);
+
+       // 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 isLoadingModels = $derived(modelsLoading());
 
-       // Get the first model for single-model mode display
-       let firstModel = $derived(models[0] ?? null);
+       // in router mode, find the model option matching modelId
+       // 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
        let modalities = $derived.by(() => {
                        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>
 
 <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>
                        @container (max-width: 56rem) {
                                .resizable-text-container {
@@ -52,7 +90,7 @@
                </Dialog.Header>
 
                <div class="space-y-6 py-4">
-                       {#if isLoadingModels}
+                       {#if isLoadingModels || isLoadingRouterProps}
                                <div class="flex items-center justify-center py-8">
                                        <div class="text-sm text-muted-foreground">Loading model information...</div>
                                </div>
                                                                        <Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
 
                                                                        <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
                                                                                                class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
                                                                                </div>
index a40501e2ccebc67fcc88052ecf97831ddec3b0a4..bf489443fa8e1add2801fd4f0419cd31033e98c7 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
        import { onMount } from 'svelte';
-       import { SvelteMap } from 'svelte/reactivity';
        import { ChevronDown, Loader2, Package } from '@lucide/svelte';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
                DialogModelInformation,
                DropdownMenuSearchable,
                ModelId,
+               ModelsSelectorList,
                ModelsSelectorOption
        } from '$lib/components/app';
        import type { ModelOption } from '$lib/types/models';
+       import { filterModelOptions, groupModelOptions, type ModelItem } from './utils';
 
        interface Props {
                class?: string;
        let searchTerm = $state('');
        let highlightedIndex = $state<number>(-1);
 
-       let filteredOptions: ModelOption[] = $derived.by(() => {
-               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))
-               );
-       });
-
-       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
-                       });
-               }
+       let filteredOptions = $derived(filterModelOptions(options, searchTerm));
 
-               // 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 groupedFilteredOptions = $derived(
+               groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) =>
+                       modelsStore.isModelLoaded(m)
+               )
+       );
 
        $effect(() => {
                void searchTerm;
 
        let isOpen = $state(false);
        let showModelDialog = $state(false);
+       let infoModelId = $state<string | null>(null);
+
+       function handleInfoClick(modelName: string) {
+               infoModelId = modelName;
+               showModelDialog = true;
+       }
 
        onMount(() => {
                modelsStore.fetch().catch((error) => {
                                                                <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
                                                        {/if}
 
-                                                       {#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
-                                                               {#if group.isLoadedGroup}
-                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
-                                                                               Loaded models
-                                                                       </p>
-                                                               {: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)}
-                                                                       {@const isSelected = currentModel === option.model || activeId === option.id}
-                                                                       {@const isHighlighted = flatIndex === highlightedIndex}
-                                                                       {@const isFav = modelsStore.favouriteModelIds.has(option.model)}
-
-                                                                       <ModelsSelectorOption
-                                                                               {option}
-                                                                               {isSelected}
-                                                                               {isHighlighted}
-                                                                               {isFav}
-                                                                               showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
-                                                                               onSelect={handleSelect}
-                                                                               onMouseEnter={() => (highlightedIndex = flatIndex)}
-                                                                               onKeyDown={(e) => {
-                                                                                       if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
-                                                                                               e.preventDefault();
-                                                                                               handleSelect(option.id);
-                                                                                       }
-                                                                               }}
-                                                                       />
-                                                               {/each}
-                                                       {/each}
+                                                       {#snippet modelOption(item: ModelItem, showOrgName: boolean)}
+                                                               {@const { option, flatIndex } = item}
+                                                               {@const isSelected = currentModel === option.model || activeId === option.id}
+                                                               {@const isHighlighted = flatIndex === highlightedIndex}
+                                                               {@const isFav = modelsStore.favouriteModelIds.has(option.model)}
+
+                                                               <ModelsSelectorOption
+                                                                       {option}
+                                                                       {isSelected}
+                                                                       {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}
+
+                                                       <ModelsSelectorList
+                                                               groups={groupedFilteredOptions}
+                                                               {currentModel}
+                                                               {activeId}
+                                                               sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
+                                                               onSelect={handleSelect}
+                                                               onInfoClick={handleInfoClick}
+                                                               renderOption={modelOption}
+                                                       />
                                                </div>
                                        </DropdownMenuSearchable>
                                </DropdownMenu.Content>
        {/if}
 </div>
 
-{#if showModelDialog && !isRouter}
-       <DialogModelInformation bind:open={showModelDialog} />
+{#if showModelDialog}
+       <DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
 {/if}
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte
new file mode 100644 (file)
index 0000000..86d7986
--- /dev/null
@@ -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}
index d4239fb1a13fc01e87b2c21a883c012a788837f5..8f44bb8de103edbacf009ae10e0e3ebab26dbb3c 100644 (file)
@@ -1,5 +1,14 @@
 <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 { ActionIcon, ModelId } from '$lib/components/app';
        import type { ModelOption } from '$lib/types/models';
@@ -15,6 +24,7 @@
                onSelect: (modelId: string) => void;
                onMouseEnter: () => void;
                onKeyDown: (e: KeyboardEvent) => void;
+               onInfoClick?: (modelName: string) => void;
        }
 
        let {
@@ -25,7 +35,8 @@
                showOrgName = false,
                onSelect,
                onMouseEnter,
-               onKeyDown
+               onKeyDown,
+               onInfoClick
        }: Props = $props();
 
        let currentRouterModels = $derived(routerModels());
                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_click_events_have_key_events -->
                <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()}
                >
                        {#if isFav}
                                        onclick={() => modelsStore.toggleFavourite(option.model)}
                                />
                        {/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>
+
                {#if isLoading}
                        <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
                {:else if isFailed}
index 6fdb3e39f3e0184549bf93548461c4448a2c6e51..26f2b72d2b22e48464f87392b4c3b1aa556cb8f2 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
        import { onMount } from 'svelte';
-       import { SvelteMap } from 'svelte/reactivity';
        import { ChevronDown, Loader2, Package } from '@lucide/svelte';
        import * as Sheet from '$lib/components/ui/sheet';
        import { cn } from '$lib/components/ui/utils';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import {
                DialogModelInformation,
+               ModelsSelectorList,
                SearchInput,
-               TruncatedText,
-               ModelsSelectorOption
+               TruncatedText
        } from '$lib/components/app';
        import type { ModelOption } from '$lib/types/models';
+       import { filterModelOptions, groupModelOptions } from './utils';
 
        interface Props {
                class?: string;
 
        let searchTerm = $state('');
 
-       let filteredOptions: ModelOption[] = $derived.by(() => {
-               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))
-               );
-       });
-
-       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
-                       });
-               }
+       let filteredOptions = $derived(filterModelOptions(options, searchTerm));
 
-               // 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 groupedFilteredOptions = $derived(
+               groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) =>
+                       modelsStore.isModelLoaded(m)
+               )
+       );
 
        let sheetOpen = $state(false);
        let showModelDialog = $state(false);
+       let infoModelId = $state<string | null>(null);
+
+       function handleInfoClick(modelName: string) {
+               infoModelId = modelName;
+               showModelDialog = true;
+       }
 
        onMount(() => {
                modelsStore.fetch().catch((error) => {
                                                                <p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
                                                        {/if}
 
-                                                       {#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
-                                                               {#if group.isLoadedGroup}
-                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
-                                                                               Loaded models
-                                                                       </p>
-                                                               {: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 } (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}
+                                                       <ModelsSelectorList
+                                                               groups={groupedFilteredOptions}
+                                                               {currentModel}
+                                                               {activeId}
+                                                               sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
+                                                               orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
+                                                               onSelect={handleSelect}
+                                                               onInfoClick={handleInfoClick}
+                                                       />
                                                </div>
                                        </div>
                                </Sheet.Content>
        {/if}
 </div>
 
-{#if showModelDialog && !isRouter}
-       <DialogModelInformation bind:open={showModelDialog} />
+{#if showModelDialog}
+       <DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
 {/if}
index 4a32be1b9d67ad1460183b2fe5d911a1edd9f66c..6a873450539550bd6777021191c3788a9604e7d7 100644 (file)
  */
 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
  *
@@ -80,5 +101,12 @@ export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.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 ModelsSelectorOption } from './ModelsSelectorOption.svelte';
diff --git a/tools/server/webui/src/lib/components/app/models/utils.ts b/tools/server/webui/src/lib/components/app/models/utils.ts
new file mode 100644 (file)
index 0000000..b3616ed
--- /dev/null
@@ -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 };
+}