Skip to content

Commit dfd3ab8

Browse files
webui: introduce OpenAI-compatible model selector in JSON payload
1 parent 38355c6 commit dfd3ab8

File tree

9 files changed

+400
-1
lines changed

9 files changed

+400
-1
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
@@ -13,6 +13,7 @@
1313
updateConversationName
1414
} from '$lib/stores/chat.svelte';
1515
import ChatSidebarActions from './ChatSidebarActions.svelte';
16+
import ModelSelector from './ModelSelector.svelte';
1617
1718
const sidebar = Sidebar.useSidebar();
1819
@@ -110,6 +111,8 @@
110111
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
111112
</a>
112113

114+
<ModelSelector />
115+
113116
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
114117
</Sidebar.Header>
115118

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { Loader2 } from '@lucide/svelte';
4+
import * as Select from '$lib/components/ui/select';
5+
import {
6+
fetchModels,
7+
modelOptions,
8+
modelsError,
9+
modelsLoading,
10+
modelsUpdating,
11+
selectModel,
12+
selectedModelId
13+
} from '$lib/stores/models.svelte';
14+
import type { ModelOption } from '$lib/stores/models.svelte';
15+
16+
let options = $derived(modelOptions());
17+
let loading = $derived(modelsLoading());
18+
let updating = $derived(modelsUpdating());
19+
let error = $derived(modelsError());
20+
let activeId = $derived(selectedModelId());
21+
22+
let isMounted = $state(false);
23+
24+
onMount(async () => {
25+
try {
26+
await fetchModels();
27+
} catch (error) {
28+
console.error('Unable to load models:', error);
29+
} finally {
30+
isMounted = true;
31+
}
32+
});
33+
34+
async function handleSelect(value: string | undefined) {
35+
if (!value) return;
36+
37+
const option = options.find((item) => item.id === value);
38+
if (!option) {
39+
console.error('Model is no longer available');
40+
return;
41+
}
42+
43+
try {
44+
await selectModel(option.id);
45+
} catch (error) {
46+
console.error('Failed to switch model:', error);
47+
}
48+
}
49+
50+
function getDisplayOption(): ModelOption | undefined {
51+
if (activeId) {
52+
return options.find((option) => option.id === activeId);
53+
}
54+
55+
return options[0];
56+
}
57+
</script>
58+
59+
{#if loading && options.length === 0 && !isMounted}
60+
<div class="flex items-center gap-2 text-xs text-muted-foreground">
61+
<Loader2 class="h-4 w-4 animate-spin" />
62+
Loading models…
63+
</div>
64+
{:else if options.length === 0}
65+
<p class="text-xs text-muted-foreground">No models available.</p>
66+
{:else}
67+
{@const selectedOption = getDisplayOption()}
68+
69+
<Select.Root
70+
type="single"
71+
value={selectedOption?.id ?? ''}
72+
onValueChange={handleSelect}
73+
disabled={loading || updating}
74+
>
75+
<Select.Trigger class="h-9 w-full justify-between">
76+
<span class="truncate text-sm font-medium">{selectedOption?.name || 'Select model'}</span>
77+
78+
{#if updating}
79+
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
80+
{/if}
81+
</Select.Trigger>
82+
83+
<Select.Content class="z-[100000]">
84+
{#each options as option (option.id)}
85+
<Select.Item value={option.id} label={option.name}>
86+
<span class="text-sm font-medium">{option.name}</span>
87+
88+
{#if option.description}
89+
<span class="text-xs text-muted-foreground">{option.description}</span>
90+
{/if}
91+
</Select.Item>
92+
{/each}
93+
</Select.Content>
94+
</Select.Root>
95+
{/if}
96+
97+
{#if error}
98+
<p class="mt-2 text-xs text-destructive">{error}</p>
99+
{/if}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export { default as ParameterSourceIndicator } from './chat/ChatSettings/Paramet
3030
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
3131
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
3232
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
33+
export { default as ChatSidebarModelSelector } from './chat/ChatSidebar/ModelSelector.svelte';
3334

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

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
@@ -118,6 +119,11 @@ export class ChatService {
118119
stream
119120
};
120121

122+
const activeModel = selectedModelName();
123+
if (activeModel) {
124+
requestBody.model = activeModel;
125+
}
126+
121127
requestBody.reasoning_format = currentConfig.disableReasoningFormat ? 'none' : 'auto';
122128

123129
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
@@ -324,12 +324,21 @@ class ChatStore {
324324
return undefined;
325325
};
326326

327+
let hasSyncedServerProps = false;
328+
327329
slotsService.startStreaming();
328330

329331
await chatService.sendMessage(allMessages, {
330332
...this.getApiOptions(),
331333

332334
onChunk: (chunk: string) => {
335+
if (!hasSyncedServerProps) {
336+
hasSyncedServerProps = true;
337+
void serverStore.fetchServerProps().catch((error) => {
338+
console.warn('Failed to refresh server props after first chunk:', error);
339+
});
340+
}
341+
333342
streamedContent += chunk;
334343
this.currentResponse = streamedContent;
335344

0 commit comments

Comments
 (0)