]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: provide touch accessible model selection UI (#24604)
authorAmos Wong <redacted>
Thu, 18 Jun 2026 11:14:20 +0000 (19:14 +0800)
committerGitHub <redacted>
Thu, 18 Jun 2026 11:14:20 +0000 (13:14 +0200)
* ui : add model selector storybook stories

Covers list, favorites, single-model, all status states
(loading/loaded/sleeping/failed/idle), and selection states.

* ui : improve model selector mobile UX with hover media queries

Use @media (hover:none) to show action buttons directly on touch
devices and color-code them by model status (amber=sleeping,
green=loaded, muted=idle). Status dots hidden on touch. Desktop
hover behavior unchanged.

tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
tools/ui/tests/stories/ModelsSelector.stories.svelte [new file with mode: 0644]

index d103d4b6711eaef1e44a6e7913cbac931fa44094..fef1490f3741ebfa2753ff50ef02aa7af13193da 100644 (file)
@@ -79,7 +79,7 @@
                <!-- svelte-ignore a11y_no_static_element_interactions -->
                <!-- svelte-ignore a11y_click_events_have_key_events -->
                <div
-                       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"
+                       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 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
                        onclick={(e) => e.stopPropagation()}
                >
                        {#if isFav}
                </div>
 
                {#if isLoading}
-                       <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
+                       <div class="flex w-4 [@media(pointer:coarse)]:w-5 items-center justify-center">
+                               <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
+                       </div>
                {:else if isFailed}
-                       <div class="flex w-4 items-center justify-center">
-                               <CircleAlert class="h-3.5 w-3.5 text-red-500 group-hover:hidden" />
+                       <div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center">
+                               <CircleAlert
+                                       class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
+                               />
 
-                               <div class="hidden group-hover:flex">
+                               <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
                                        <ActionIcon
                                                iconSize="h-2.5 w-2.5"
                                                icon={RotateCw}
                                </div>
                        </div>
                {:else if isSleeping}
-                       <div class="flex w-4 items-center justify-center">
-                               <span class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden"></span>
+                       <div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center">
+                               <span
+                                       class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden"
+                               ></span>
 
-                               <div class="hidden group-hover:flex">
+                               <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
                                        <ActionIcon
                                                iconSize="h-2.5 w-2.5"
                                                icon={PowerOff}
                                                tooltip="Unload model"
-                                               class="h-3 w-3 text-red-500 hover:text-red-600"
+                                               class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
                                                onclick={(e) => {
                                                        e?.stopPropagation();
                                                        modelsStore.unloadModel(option.model);
                                </div>
                        </div>
                {:else if isLoaded}
-                       <div class="flex w-4 items-center justify-center">
-                               <span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span>
+                       <div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center">
+                               <span
+                                       class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
+                               ></span>
 
-                               <div class="hidden group-hover:flex">
+                               <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
                                        <ActionIcon
                                                iconSize="h-2.5 w-2.5"
                                                icon={PowerOff}
                                                tooltip="Unload model"
-                                               class="h-3 w-3 text-red-500 hover:text-red-600"
+                                               class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
                                                onclick={() => modelsStore.unloadModel(option.model)}
                                                stopPropagationOnClick
                                        />
                                </div>
                        </div>
                {:else}
-                       <div class="flex w-4 items-center justify-center">
-                               <span class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden"></span>
+                       <div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center">
+                               <span
+                                       class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden"
+                               ></span>
 
-                               <div class="hidden group-hover:flex">
+                               <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
                                        <ActionIcon
                                                iconSize="h-2.5 w-2.5"
                                                icon={Power}
                                                tooltip="Load model"
-                                               class="h-3 w-3"
+                                               class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
                                                onclick={() => modelsStore.loadModel(option.model)}
                                                stopPropagationOnClick
                                        />
index 2ddbf24055b8b01fc4d11687a869bc4d498e76dc..e5920a2a0f497522db7e6be9b664e711eda065c9 100644 (file)
@@ -66,7 +66,7 @@
                        <button
                                type="button"
                                class={[
-                                       `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
+                                       `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 max-sm:px-3 max-sm:py-2 text-xs max-sm:text-sm shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
                                        !ms.isCurrentModelInCache
                                                ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
                                                : forceForegroundText
diff --git a/tools/ui/tests/stories/ModelsSelector.stories.svelte b/tools/ui/tests/stories/ModelsSelector.stories.svelte
new file mode 100644 (file)
index 0000000..3aba0dc
--- /dev/null
@@ -0,0 +1,269 @@
+<script module lang="ts">
+       import { defineMeta } from '@storybook/addon-svelte-csf';
+       import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
+       import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
+       import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
+       import { modelsStore } from '$lib/stores/models.svelte';
+       import { ServerModelStatus } from '$lib/enums';
+
+       const { Story } = defineMeta({
+               title: 'Components/ModelsSelector',
+               parameters: {
+                       layout: 'centered'
+               }
+       });
+
+       const mockModel = (id: string, name: string, orgName?: string, tags?: string[]): ModelOption => ({
+               id,
+               name,
+               model: orgName ? `${orgName}/${name}` : name,
+               capabilities: [],
+               parsedId: {
+                       raw: orgName ? `${orgName}/${name}` : name,
+                       orgName: orgName ?? null,
+                       modelName: name,
+                       params: null,
+                       activatedParams: null,
+                       quantization: null,
+                       tags: tags ?? []
+               },
+               tags
+       });
+
+       const mockRouterEntry = (modelName: string, status: ServerModelStatus): ApiModelDataEntry => ({
+               id: modelName,
+               object: 'model',
+               owned_by: 'llamacpp',
+               created: Date.now(),
+               in_cache: true,
+               path: `/models/${modelName}`,
+               status: { value: status }
+       });
+</script>
+
+<script lang="ts">
+       let selectedModel = $state<string | null>(null);
+       let activeId = $state<string | null>(null);
+
+       function mockModelsStore() {
+               modelsStore.favoriteModelIds = new Set(['qwen2.5-7b', 'llama3.2-3b']);
+
+               // Mock router models with various statuses for ModelLoadedStates story
+               modelsStore.routerModels = [
+                       mockRouterEntry('meta/Model (loading)', ServerModelStatus.LOADING),
+                       mockRouterEntry('meta/Model (loaded)', ServerModelStatus.LOADED),
+                       mockRouterEntry('meta/Model (sleeping)', ServerModelStatus.SLEEPING),
+                       mockRouterEntry('meta/Model (failed)', ServerModelStatus.FAILED)
+               ];
+       }
+
+       mockModelsStore();
+
+       const loadedModels: ModelItem[] = [
+               { option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta'), flatIndex: 0 },
+               { option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai'), flatIndex: 1 }
+       ];
+
+       const favoriteModels: ModelItem[] = [
+               { option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen'), flatIndex: 2 },
+               { option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta'), flatIndex: 3 }
+       ];
+
+       const availableModels: ModelItem[] = [
+               {
+                       option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding']),
+                       flatIndex: 4
+               },
+               { option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google'), flatIndex: 5 },
+               { option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft'), flatIndex: 6 },
+               { option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']), flatIndex: 7 },
+               { option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel'), flatIndex: 8 }
+       ];
+
+       const groupedOptions: GroupedModelOptions = {
+               loaded: loadedModels,
+               favorites: favoriteModels,
+               available: [
+                       {
+                               orgName: 'deepseek',
+                               items: [availableModels[0]]
+                       },
+                       {
+                               orgName: 'google',
+                               items: [availableModels[1]]
+                       },
+                       {
+                               orgName: 'microsoft',
+                               items: [availableModels[2]]
+                       },
+                       {
+                               orgName: 'codellama',
+                               items: [availableModels[3]]
+                       },
+                       {
+                               orgName: 'intel',
+                               items: [availableModels[4]]
+                       }
+               ]
+       };
+
+       function handleSelect(modelId: string) {
+               const opt = [...loadedModels, ...favoriteModels, ...availableModels].find(
+                       (m) => m.option.id === modelId
+               );
+               if (opt) {
+                       selectedModel = opt.option.model;
+                       activeId = modelId;
+               }
+       }
+</script>
+
+<Story name="List">
+       <div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
+               <ModelsSelectorList
+                       groups={groupedOptions}
+                       currentModel={selectedModel}
+                       {activeId}
+                       onSelect={handleSelect}
+                       onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
+               />
+       </div>
+</Story>
+
+<Story name="SingleLoaded">
+       <div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
+               <ModelsSelectorList
+                       groups={{
+                               loaded: [loadedModels[0]],
+                               favorites: [],
+                               available: []
+                       }}
+                       currentModel={null}
+                       activeId={null}
+                       onSelect={handleSelect}
+                       onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
+               />
+       </div>
+</Story>
+
+<Story name="WithFavoritesOnly">
+       <div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
+               <ModelsSelectorList
+                       groups={{
+                               loaded: [],
+                               favorites: favoriteModels,
+                               available: []
+                       }}
+                       currentModel={null}
+                       activeId={null}
+                       onSelect={handleSelect}
+                       onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
+               />
+       </div>
+</Story>
+
+<Story name="ModelLoadedStates">
+       <div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
+               <div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
+                       Server model states
+               </div>
+               <ModelsSelectorOption
+                       option={mockModel('model-idle', 'Model (idle)', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('model-loading', 'Model (loading)', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('model-loaded', 'Model (loaded)', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('model-sleeping', 'Model (sleeping)', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('model-failed', 'Model (failed)', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+       </div>
+</Story>
+
+<Story name="ModelSelectedStates">
+       <div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
+               <div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
+                       Selection states
+               </div>
+               <ModelsSelectorOption
+                       option={mockModel('normal-model', 'Normal Model', 'meta')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('selected-model', 'Selected Model', 'meta')}
+                       isSelected={true}
+                       isHighlighted={false}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('highlighted-model', 'Highlighted Model', 'meta')}
+                       isSelected={false}
+                       isHighlighted={true}
+                       isFav={false}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+               <ModelsSelectorOption
+                       option={mockModel('fav-model', 'Favorite Model', 'Qwen')}
+                       isSelected={false}
+                       isHighlighted={false}
+                       isFav={true}
+                       hideOrgName={true}
+                       onSelect={() => {}}
+                       onMouseEnter={() => {}}
+                       onKeyDown={() => {}}
+               />
+       </div>
+</Story>