Skip to content

Commit 232f4df

Browse files
feat: add dynamic model selector with persistence for llama-swap workflows
- Add ModelSelector component in ChatSidebar with dropdown interface - Implement models store with localStorage persistence and auto-selection - Add ModelsService for /v1/models API integration with multi-model setups - Inject selected model into chat completion requests - Display model capabilities with badges (Vision, Audio, etc.) - Auto-refresh server props after model usage for accurate state Particularly useful for llama-swap users managing multiple models dynamically.
1 parent d72f5f7 commit 232f4df

File tree

8 files changed

+472
-0
lines changed

8 files changed

+472
-0
lines changed

tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
updateConversationName
1111
} from '$lib/stores/chat.svelte';
1212
import ChatSidebarActions from './ChatSidebarActions.svelte';
13+
import ModelSelector from './ModelSelector.svelte';
1314
1415
const sidebar = Sidebar.useSidebar();
1516
@@ -74,6 +75,8 @@
7475
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
7576
</a>
7677

78+
<ModelSelector />
79+
7780
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
7881
</Sidebar.Header>
7982

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { Loader2, RefreshCw } from '@lucide/svelte';
4+
import * as Select from '$lib/components/ui/select';
5+
import { Button } from '$lib/components/ui/button';
6+
import { Badge } from '$lib/components/ui/badge';
7+
import {
8+
fetchModels,
9+
modelOptions,
10+
modelsError,
11+
modelsLoading,
12+
modelsUpdating,
13+
selectModel,
14+
selectedModelId
15+
} from '$lib/stores/models.svelte';
16+
import type { ModelOption } from '$lib/stores/models.svelte';
17+
import { toast } from 'svelte-sonner';
18+
19+
let options = $derived(modelOptions());
20+
let loading = $derived(modelsLoading());
21+
let updating = $derived(modelsUpdating());
22+
let error = $derived(modelsError());
23+
let activeId = $derived(selectedModelId());
24+
25+
let isMounted = $state(false);
26+
27+
onMount(async () => {
28+
try {
29+
await fetchModels();
30+
} catch (error) {
31+
reportError('Unable to load models', error);
32+
} finally {
33+
isMounted = true;
34+
}
35+
});
36+
37+
async function handleRefresh() {
38+
try {
39+
await fetchModels(true);
40+
toast.success('Model list refreshed');
41+
} catch (error) {
42+
reportError('Failed to refresh model list', error);
43+
}
44+
}
45+
46+
async function handleSelect(value: string | undefined) {
47+
if (!value) return;
48+
49+
const option = options.find((item) => item.id === value);
50+
if (!option) {
51+
reportError('Model is no longer available', new Error('Unknown model'));
52+
return;
53+
}
54+
55+
try {
56+
await selectModel(option.id);
57+
toast.success(`Switched to ${option.name}`);
58+
} catch (error) {
59+
reportError('Failed to switch model', error);
60+
}
61+
}
62+
63+
function reportError(message: string, error: unknown) {
64+
const description = error instanceof Error ? error.message : 'Unknown error';
65+
toast.error(message, { description });
66+
}
67+
68+
function getDisplayOption(): ModelOption | undefined {
69+
if (activeId) {
70+
return options.find((option) => option.id === activeId);
71+
}
72+
73+
return options[0];
74+
}
75+
76+
function getCapabilityLabel(capability: string): string {
77+
switch (capability.toLowerCase()) {
78+
case 'vision':
79+
return 'Vision';
80+
case 'audio':
81+
return 'Audio';
82+
case 'multimodal':
83+
return 'Multimodal';
84+
case 'completion':
85+
return 'Text';
86+
default:
87+
return capability;
88+
}
89+
}
90+
</script>
91+
92+
<div class="rounded-lg border border-border/40 bg-background/5 p-3 shadow-sm">
93+
<div class="mb-2 flex items-center justify-between">
94+
<p class="text-xs font-medium text-muted-foreground">Model selector</p>
95+
96+
<Button
97+
aria-label="Refresh model list"
98+
class="h-7 w-7"
99+
disabled={loading}
100+
onclick={handleRefresh}
101+
size="icon"
102+
variant="ghost"
103+
>
104+
{#if loading}
105+
<Loader2 class="h-4 w-4 animate-spin" />
106+
{:else}
107+
<RefreshCw class="h-4 w-4" />
108+
{/if}
109+
</Button>
110+
</div>
111+
112+
{#if loading && options.length === 0 && !isMounted}
113+
<div class="flex items-center gap-2 text-xs text-muted-foreground">
114+
<Loader2 class="h-4 w-4 animate-spin" />
115+
Loading models…
116+
</div>
117+
{:else if options.length === 0}
118+
<p class="text-xs text-muted-foreground">No models available.</p>
119+
{:else}
120+
{@const selectedOption = getDisplayOption()}
121+
122+
<Select.Root
123+
type="single"
124+
value={selectedOption?.id ?? ''}
125+
onValueChange={handleSelect}
126+
disabled={loading || updating}
127+
>
128+
<Select.Trigger class="h-9 w-full justify-between">
129+
<span class="truncate text-sm font-medium">{selectedOption?.name || 'Select model'}</span>
130+
131+
{#if updating}
132+
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
133+
{/if}
134+
</Select.Trigger>
135+
136+
<Select.Content class="z-[100000]">
137+
{#each options as option (option.id)}
138+
<Select.Item value={option.id} label={option.name}>
139+
<div class="flex flex-col gap-1">
140+
<span class="text-sm font-medium">{option.name}</span>
141+
142+
{#if option.description}
143+
<span class="text-xs text-muted-foreground">{option.description}</span>
144+
{/if}
145+
146+
{#if option.capabilities.length > 0}
147+
<div class="flex flex-wrap gap-1">
148+
{#each option.capabilities as capability (capability)}
149+
<Badge variant="secondary" class="text-[10px]">
150+
{getCapabilityLabel(capability)}
151+
</Badge>
152+
{/each}
153+
</div>
154+
{/if}
155+
</div>
156+
</Select.Item>
157+
{/each}
158+
</Select.Content>
159+
</Select.Root>
160+
161+
{#if selectedOption?.capabilities.length}
162+
<div class="mt-3 flex flex-wrap gap-1">
163+
{#each selectedOption.capabilities as capability (capability)}
164+
<Badge variant="outline" class="text-[10px]">
165+
{getCapabilityLabel(capability)}
166+
</Badge>
167+
{/each}
168+
</div>
169+
{/if}
170+
{/if}
171+
172+
{#if error}
173+
<p class="mt-2 text-xs text-destructive">{error}</p>
174+
{/if}
175+
</div>

tools/server/webui/src/lib/components/app/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsF
2929
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
3030
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
3131
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
32+
export { default as ChatSidebarModelSelector } from './chat/ChatSidebar/ModelSelector.svelte';
3233

3334
export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
3435

tools/server/webui/src/lib/services/chat.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from '$lib/stores/settings.svelte';
2+
import { selectedModelName } from '$lib/stores/models.svelte';
23
import { slotsService } from './slots';
34
/**
45
* ChatService - Low-level API communication layer for llama.cpp server interactions
@@ -117,6 +118,11 @@ export class ChatService {
117118
stream
118119
};
119120

121+
const activeModel = selectedModelName();
122+
if (activeModel) {
123+
requestBody.model = activeModel;
124+
}
125+
120126
requestBody.reasoning_format = 'auto';
121127

122128
if (temperature !== undefined) requestBody.temperature = temperature;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { base } from '$app/paths';
2+
import { config } from '$lib/stores/settings.svelte';
3+
import type { ApiModelListResponse } from '$lib/types/api';
4+
5+
export class ModelsService {
6+
static async list(): Promise<ApiModelListResponse> {
7+
const currentConfig = config();
8+
const apiKey = currentConfig.apiKey?.toString().trim();
9+
10+
const response = await fetch(`${base}/v1/models`, {
11+
headers: {
12+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
13+
}
14+
});
15+
16+
if (!response.ok) {
17+
throw new Error(`Failed to fetch model list (status ${response.status})`);
18+
}
19+
20+
return response.json() as Promise<ApiModelListResponse>;
21+
}
22+
}

tools/server/webui/src/lib/stores/chat.svelte.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,21 @@ class ChatStore {
313313

314314
let streamedReasoningContent = '';
315315

316+
let hasSyncedServerProps = false;
317+
316318
slotsService.startStreaming();
317319

318320
await chatService.sendMessage(allMessages, {
319321
...this.getApiOptions(),
320322

321323
onChunk: (chunk: string) => {
324+
if (!hasSyncedServerProps) {
325+
hasSyncedServerProps = true;
326+
void serverStore.fetchServerProps().catch((error) => {
327+
console.warn('Failed to refresh server props after first chunk:', error);
328+
});
329+
}
330+
322331
streamedContent += chunk;
323332
this.currentResponse = streamedContent;
324333

0 commit comments

Comments
 (0)