ui: provide touch accessible model selection UI (#24604)

* 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.
This commit is contained in:
Amos Wong
2026-06-18 19:14:20 +08:00
committed by GitHub
parent 10786217e9
commit 20832179e2
3 changed files with 297 additions and 18 deletions

View 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}
@@ -113,12 +113,16 @@
</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}
@@ -130,15 +134,17 @@
</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);
@@ -147,30 +153,34 @@
</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
/>

View 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

View File

@@ -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>