diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz
index 08450a93cb3f4..7b56d87e430ea 100644
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte
index a6f3c7320826f..ef03f73f8de8f 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte
@@ -3,6 +3,8 @@
import { Button } from '$lib/components/ui/button';
import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte';
import ChatFormActionRecord from './ChatFormActionRecord.svelte';
+ import ChatFormModelSelector from './ChatFormModelSelector.svelte';
+ import { config } from '$lib/stores/settings.svelte';
import type { FileTypeCategory } from '$lib/enums/files';
interface Props {
@@ -26,32 +28,36 @@
onMicClick,
onStop
}: Props = $props();
+
+ let currentConfig = $derived(config());
-
-
+
+
+
+ {#if currentConfig.modelSelectorEnabled}
+
+ {/if}
-
- {#if isLoading}
-
- {:else}
-
+ {#if isLoading}
+
+ {:else}
+
-
- {/if}
-
+
+ {/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte
new file mode 100644
index 0000000000000..689415f8df84b
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+ {#if loading && options.length === 0 && !isMounted}
+
+
+ Loading models…
+
+ {:else if options.length === 0}
+
No models available.
+ {:else}
+ {@const selectedOption = getDisplayOption()}
+
+
+
+
+ {#if isOpen}
+
+
0
+ ? `${menuPosition.maxHeight}px`
+ : undefined}
+ >
+ {#each options as option (option.id)}
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
index 5539ed9e21c34..e878e7bf8a217 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
@@ -10,6 +10,7 @@
import ChatMessageActions from './ChatMessageActions.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
+ import { modelName as serverModelName } from '$lib/stores/server.svelte';
import { copyToClipboard } from '$lib/utils/copy';
interface Props {
@@ -70,6 +71,23 @@
}: Props = $props();
const processingState = useProcessingState();
+ let currentConfig = $derived(config());
+ let serverModel = $derived(serverModelName());
+ let displayedModel = $derived((): string | null => {
+ if (!currentConfig.showModelInfo) return null;
+
+ if (currentConfig.modelSelectorEnabled) {
+ return message.model ?? null;
+ }
+
+ return serverModel;
+ });
+
+ function handleCopyModel() {
+ const model = displayedModel();
+
+ void copyToClipboard(model ?? '');
+ }
{/if}
- {#if config().showModelInfo && message.model}
+ {#if displayedModel()}
@@ -150,9 +168,9 @@
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
index ad5d617b5ff64..20e4d3b3324e8 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
@@ -216,6 +216,11 @@
title: 'Developer',
icon: Code,
fields: [
+ {
+ key: 'modelSelectorEnabled',
+ label: 'Enable model selector',
+ type: 'checkbox'
+ },
{
key: 'disableReasoningFormat',
label: 'Show raw LLM output',
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts
index 7b85db93db3f5..392132f442fd3 100644
--- a/tools/server/webui/src/lib/components/app/index.ts
+++ b/tools/server/webui/src/lib/components/app/index.ts
@@ -8,6 +8,7 @@ export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.sv
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte';
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte';
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActionRecord.svelte';
+export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
@@ -32,7 +33,6 @@ export { default as ParameterSourceIndicator } from './chat/ChatSettings/Paramet
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
-
export { default as ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte';
export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
diff --git a/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte b/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte
index a7839d1c4dcd7..5bc28eeb47bf3 100644
--- a/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte
+++ b/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte
@@ -8,22 +8,33 @@
class: className,
children,
size = 'default',
+ variant = 'default',
...restProps
}: WithoutChild & {
size?: 'sm' | 'default';
+ variant?: 'default' | 'plain';
} = $props();
+
+ const baseClasses = $derived(
+ variant === 'plain'
+ ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
+ : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
+ );
+
+ const chevronClasses = $derived(
+ variant === 'plain'
+ ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
+ : 'size-4 opacity-50'
+ );
{@render children?.()}
-
+
diff --git a/tools/server/webui/src/lib/constants/localstorage-keys.ts b/tools/server/webui/src/lib/constants/localstorage-keys.ts
index 9fcc7bab93d1d..8bdc5f33c38a9 100644
--- a/tools/server/webui/src/lib/constants/localstorage-keys.ts
+++ b/tools/server/webui/src/lib/constants/localstorage-keys.ts
@@ -1 +1,2 @@
export const SERVER_PROPS_LOCALSTORAGE_KEY = 'LlamaCppWebui.serverProps';
+export const SELECTED_MODEL_LOCALSTORAGE_KEY = 'LlamaCppWebui.selectedModel';
diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts
index 154ec888ce2dc..512dcc96997e7 100644
--- a/tools/server/webui/src/lib/constants/settings-config.ts
+++ b/tools/server/webui/src/lib/constants/settings-config.ts
@@ -13,6 +13,7 @@ export const SETTING_CONFIG_DEFAULT: Record =
pdfAsImage: false,
showModelInfo: false,
renderUserContentAsMarkdown: false,
+ modelSelectorEnabled: false,
// make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature',
temperature: 0.8,
@@ -86,6 +87,8 @@ export const SETTING_CONFIG_INFO: Record = {
pdfAsImage: 'Parse PDF as image instead of text (requires vision-capable model).',
showModelInfo: 'Display the model name used to generate each message below the message content.',
renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.',
+ modelSelectorEnabled:
+ 'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.',
pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.'
};
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
index 2c4e53a02b2da..df03b10251ac2 100644
--- a/tools/server/webui/src/lib/services/chat.ts
+++ b/tools/server/webui/src/lib/services/chat.ts
@@ -1,4 +1,5 @@
import { config } from '$lib/stores/settings.svelte';
+import { selectedModelName } from '$lib/stores/models.svelte';
import { slotsService } from './slots';
/**
* ChatService - Low-level API communication layer for llama.cpp server interactions
@@ -51,6 +52,8 @@ export class ChatService {
onChunk,
onComplete,
onError,
+ onReasoningChunk,
+ onModel,
// Generation parameters
temperature,
max_tokens,
@@ -118,6 +121,13 @@ export class ChatService {
stream
};
+ const modelSelectorEnabled = Boolean(currentConfig.modelSelectorEnabled);
+ const activeModel = modelSelectorEnabled ? selectedModelName() : null;
+
+ if (modelSelectorEnabled && activeModel) {
+ requestBody.model = activeModel;
+ }
+
requestBody.reasoning_format = currentConfig.disableReasoningFormat ? 'none' : 'auto';
if (temperature !== undefined) requestBody.temperature = temperature;
@@ -189,13 +199,14 @@ export class ChatService {
onChunk,
onComplete,
onError,
- options.onReasoningChunk,
+ onReasoningChunk,
+ onModel,
conversationId,
abortController.signal
);
return;
} else {
- return this.handleNonStreamResponse(response, onComplete, onError);
+ return this.handleNonStreamResponse(response, onComplete, onError, onModel);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
@@ -255,6 +266,7 @@ export class ChatService {
) => void,
onError?: (error: Error) => void,
onReasoningChunk?: (chunk: string) => void,
+ onModel?: (model: string) => void,
conversationId?: string,
abortSignal?: AbortSignal
): Promise {
@@ -270,6 +282,7 @@ export class ChatService {
let hasReceivedData = false;
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
+ let modelEmitted = false;
try {
let chunk = '';
@@ -298,6 +311,12 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
+ const chunkModel = this.extractModelName(parsed);
+ if (chunkModel && !modelEmitted) {
+ modelEmitted = true;
+ onModel?.(chunkModel);
+ }
+
const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const timings = parsed.timings;
@@ -372,7 +391,8 @@ export class ChatService {
reasoningContent?: string,
timings?: ChatMessageTimings
) => void,
- onError?: (error: Error) => void
+ onError?: (error: Error) => void,
+ onModel?: (model: string) => void
): Promise {
try {
const responseText = await response.text();
@@ -383,6 +403,12 @@ export class ChatService {
}
const data: ApiChatCompletionResponse = JSON.parse(responseText);
+
+ const responseModel = this.extractModelName(data);
+ if (responseModel) {
+ onModel?.(responseModel);
+ }
+
const content = data.choices[0]?.message?.content || '';
const reasoningContent = data.choices[0]?.message?.reasoning_content;
@@ -625,6 +651,39 @@ export class ChatService {
}
}
+ private extractModelName(data: unknown): string | undefined {
+ const asRecord = (value: unknown): Record | undefined => {
+ return typeof value === 'object' && value !== null
+ ? (value as Record)
+ : undefined;
+ };
+
+ const getTrimmedString = (value: unknown): string | undefined => {
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
+ };
+
+ const root = asRecord(data);
+ if (!root) return undefined;
+
+ // 1) root (some implementations provide `model` at the top level)
+ const rootModel = getTrimmedString(root.model);
+ if (rootModel) return rootModel;
+
+ // 2) streaming choice (delta) or final response (message)
+ const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
+ if (!firstChoice) return undefined;
+
+ // priority: delta.model (first chunk) else message.model (final response)
+ const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
+ if (deltaModel) return deltaModel;
+
+ const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
+ if (messageModel) return messageModel;
+
+ // avoid guessing from non-standard locations (metadata, etc.)
+ return undefined;
+ }
+
private updateProcessingState(
timings?: ChatMessageTimings,
promptProgress?: ChatMessagePromptProgress,
diff --git a/tools/server/webui/src/lib/services/models.ts b/tools/server/webui/src/lib/services/models.ts
new file mode 100644
index 0000000000000..1c7fa3b45631c
--- /dev/null
+++ b/tools/server/webui/src/lib/services/models.ts
@@ -0,0 +1,22 @@
+import { base } from '$app/paths';
+import { config } from '$lib/stores/settings.svelte';
+import type { ApiModelListResponse } from '$lib/types/api';
+
+export class ModelsService {
+ static async list(): Promise {
+ const currentConfig = config();
+ const apiKey = currentConfig.apiKey?.toString().trim();
+
+ const response = await fetch(`${base}/v1/models`, {
+ headers: {
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch model list (status ${response.status})`);
+ }
+
+ return response.json() as Promise;
+ }
+}
diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts
index ccc67c7294263..a2e74a2e10721 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -1,7 +1,7 @@
import { DatabaseStore } from '$lib/stores/database';
import { chatService, slotsService } from '$lib/services';
-import { serverStore } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
+import { normalizeModelName } from '$lib/utils/model-names';
import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
@@ -359,28 +359,33 @@ class ChatStore {
): Promise {
let streamedContent = '';
let streamedReasoningContent = '';
- let modelCaptured = false;
- const captureModelIfNeeded = (updateDbImmediately = true): string | undefined => {
- if (!modelCaptured) {
- const currentModelName = serverStore.modelName;
+ let resolvedModel: string | null = null;
+ let modelPersisted = false;
- if (currentModelName) {
- if (updateDbImmediately) {
- DatabaseStore.updateMessage(assistantMessage.id, { model: currentModelName }).catch(
- console.error
- );
- }
+ const recordModel = (modelName: string, persistImmediately = true): void => {
+ const normalizedModel = normalizeModelName(modelName);
- const messageIndex = this.findMessageIndex(assistantMessage.id);
+ if (!normalizedModel || normalizedModel === resolvedModel) {
+ return;
+ }
- this.updateMessageAtIndex(messageIndex, { model: currentModelName });
- modelCaptured = true;
+ resolvedModel = normalizedModel;
- return currentModelName;
- }
+ const messageIndex = this.findMessageIndex(assistantMessage.id);
+
+ this.updateMessageAtIndex(messageIndex, { model: normalizedModel });
+
+ if (persistImmediately && !modelPersisted) {
+ modelPersisted = true;
+ DatabaseStore.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(
+ (error) => {
+ console.error('Failed to persist model name:', error);
+ modelPersisted = false;
+ resolvedModel = null;
+ }
+ );
}
- return undefined;
};
slotsService.startStreaming();
@@ -399,7 +404,6 @@ class ChatStore {
assistantMessage.id
);
- captureModelIfNeeded();
const messageIndex = this.findMessageIndex(assistantMessage.id);
this.updateMessageAtIndex(messageIndex, {
content: streamedContent
@@ -409,13 +413,15 @@ class ChatStore {
onReasoningChunk: (reasoningChunk: string) => {
streamedReasoningContent += reasoningChunk;
- captureModelIfNeeded();
-
const messageIndex = this.findMessageIndex(assistantMessage.id);
this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
},
+ onModel: (modelName: string) => {
+ recordModel(modelName);
+ },
+
onComplete: async (
finalContent?: string,
reasoningContent?: string,
@@ -434,10 +440,9 @@ class ChatStore {
timings: timings
};
- const capturedModel = captureModelIfNeeded(false);
-
- if (capturedModel) {
- updateData.model = capturedModel;
+ if (resolvedModel && !modelPersisted) {
+ updateData.model = resolvedModel;
+ modelPersisted = true;
}
await DatabaseStore.updateMessage(assistantMessage.id, updateData);
@@ -565,7 +570,8 @@ class ChatStore {
content: '',
timestamp: Date.now(),
thinking: '',
- children: []
+ children: [],
+ model: null
},
parentId || null
);
@@ -1533,7 +1539,8 @@ class ChatStore {
role: 'assistant',
content: '',
thinking: '',
- children: []
+ children: [],
+ model: null
},
parentMessage.id
);
@@ -1590,7 +1597,8 @@ class ChatStore {
role: 'assistant',
content: '',
thinking: '',
- children: []
+ children: [],
+ model: null
},
userMessageId
);
diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts
new file mode 100644
index 0000000000000..bcb68826ce839
--- /dev/null
+++ b/tools/server/webui/src/lib/stores/models.svelte.ts
@@ -0,0 +1,187 @@
+import { ModelsService } from '$lib/services/models';
+import { persisted } from '$lib/stores/persisted.svelte';
+import { SELECTED_MODEL_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
+import type { ModelOption } from '$lib/types/models';
+
+type PersistedModelSelection = {
+ id: string;
+ model: string;
+};
+
+class ModelsStore {
+ private _models = $state([]);
+ private _loading = $state(false);
+ private _updating = $state(false);
+ private _error = $state(null);
+ private _selectedModelId = $state(null);
+ private _selectedModelName = $state(null);
+ private _persistedSelection = persisted(
+ SELECTED_MODEL_LOCALSTORAGE_KEY,
+ null
+ );
+
+ constructor() {
+ const persisted = this._persistedSelection.value;
+ if (persisted) {
+ this._selectedModelId = persisted.id;
+ this._selectedModelName = persisted.model;
+ }
+ }
+
+ get models(): ModelOption[] {
+ return this._models;
+ }
+
+ get loading(): boolean {
+ return this._loading;
+ }
+
+ get updating(): boolean {
+ return this._updating;
+ }
+
+ get error(): string | null {
+ return this._error;
+ }
+
+ get selectedModelId(): string | null {
+ return this._selectedModelId;
+ }
+
+ get selectedModelName(): string | null {
+ return this._selectedModelName;
+ }
+
+ get selectedModel(): ModelOption | null {
+ if (!this._selectedModelId) {
+ return null;
+ }
+
+ return this._models.find((model) => model.id === this._selectedModelId) ?? null;
+ }
+
+ async fetch(force = false): Promise {
+ if (this._loading) return;
+ if (this._models.length > 0 && !force) return;
+
+ this._loading = true;
+ this._error = null;
+
+ try {
+ const response = await ModelsService.list();
+
+ const models: ModelOption[] = response.data.map((item, index) => {
+ const details = response.models?.[index];
+ const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
+ const displayNameSource =
+ details?.name && details.name.trim().length > 0 ? details.name : item.id;
+ const displayName = this.toDisplayName(displayNameSource);
+
+ return {
+ id: item.id,
+ name: displayName,
+ model: details?.model || item.id,
+ description: details?.description,
+ capabilities: rawCapabilities.filter((value): value is string => Boolean(value)),
+ details: details?.details,
+ meta: item.meta ?? null
+ } satisfies ModelOption;
+ });
+
+ this._models = models;
+
+ const selection = this.determineInitialSelection(models);
+
+ this._selectedModelId = selection.id;
+ this._selectedModelName = selection.model;
+ this._persistedSelection.value =
+ selection.id && selection.model ? { id: selection.id, model: selection.model } : null;
+ } catch (error) {
+ this._models = [];
+ this._error = error instanceof Error ? error.message : 'Failed to load models';
+
+ throw error;
+ } finally {
+ this._loading = false;
+ }
+ }
+
+ async select(modelId: string): Promise {
+ if (!modelId || this._updating) {
+ return;
+ }
+
+ if (this._selectedModelId === modelId) {
+ return;
+ }
+
+ const option = this._models.find((model) => model.id === modelId);
+ if (!option) {
+ throw new Error('Selected model is not available');
+ }
+
+ this._updating = true;
+ this._error = null;
+
+ try {
+ this._selectedModelId = option.id;
+ this._selectedModelName = option.model;
+ this._persistedSelection.value = { id: option.id, model: option.model };
+ } finally {
+ this._updating = false;
+ }
+ }
+
+ private toDisplayName(id: string): string {
+ const segments = id.split(/\\|\//);
+ const candidate = segments.pop();
+
+ return candidate && candidate.trim().length > 0 ? candidate : id;
+ }
+
+ /**
+ * Determines which model should be selected after fetching the models list.
+ * Priority: current selection > persisted selection > first available model > none
+ */
+ private determineInitialSelection(models: ModelOption[]): {
+ id: string | null;
+ model: string | null;
+ } {
+ const persisted = this._persistedSelection.value;
+ let nextSelectionId = this._selectedModelId ?? persisted?.id ?? null;
+ let nextSelectionName = this._selectedModelName ?? persisted?.model ?? null;
+
+ if (nextSelectionId) {
+ const match = models.find((m) => m.id === nextSelectionId);
+
+ if (match) {
+ nextSelectionId = match.id;
+ nextSelectionName = match.model;
+ } else if (models[0]) {
+ nextSelectionId = models[0].id;
+ nextSelectionName = models[0].model;
+ } else {
+ nextSelectionId = null;
+ nextSelectionName = null;
+ }
+ } else if (models[0]) {
+ nextSelectionId = models[0].id;
+ nextSelectionName = models[0].model;
+ }
+
+ return { id: nextSelectionId, model: nextSelectionName };
+ }
+}
+
+export const modelsStore = new ModelsStore();
+
+export const modelOptions = () => modelsStore.models;
+export const modelsLoading = () => modelsStore.loading;
+export const modelsUpdating = () => modelsStore.updating;
+export const modelsError = () => modelsStore.error;
+export const selectedModelId = () => modelsStore.selectedModelId;
+export const selectedModelName = () => modelsStore.selectedModelName;
+export const selectedModelOption = () => modelsStore.selectedModel;
+
+export const fetchModels = modelsStore.fetch.bind(modelsStore);
+export const selectModel = modelsStore.select.bind(modelsStore);
diff --git a/tools/server/webui/src/lib/stores/persisted.svelte.ts b/tools/server/webui/src/lib/stores/persisted.svelte.ts
new file mode 100644
index 0000000000000..1e07f80ed7275
--- /dev/null
+++ b/tools/server/webui/src/lib/stores/persisted.svelte.ts
@@ -0,0 +1,50 @@
+import { browser } from '$app/environment';
+
+type PersistedValue = {
+ get value(): T;
+ set value(newValue: T);
+};
+
+export function persisted(key: string, initialValue: T): PersistedValue {
+ let value = initialValue;
+
+ if (browser) {
+ try {
+ const stored = localStorage.getItem(key);
+
+ if (stored !== null) {
+ value = JSON.parse(stored) as T;
+ }
+ } catch (error) {
+ console.warn(`Failed to load ${key}:`, error);
+ }
+ }
+
+ const persist = (next: T) => {
+ if (!browser) {
+ return;
+ }
+
+ try {
+ if (next === null || next === undefined) {
+ localStorage.removeItem(key);
+ return;
+ }
+
+ localStorage.setItem(key, JSON.stringify(next));
+ } catch (error) {
+ console.warn(`Failed to persist ${key}:`, error);
+ }
+ };
+
+ return {
+ get value() {
+ return value;
+ },
+
+ set value(newValue: T) {
+ value = newValue;
+ persist(newValue);
+ }
+ };
+}
diff --git a/tools/server/webui/src/lib/stores/settings.svelte.ts b/tools/server/webui/src/lib/stores/settings.svelte.ts
index b330cbb4bf42e..b10f0dd3a4189 100644
--- a/tools/server/webui/src/lib/stores/settings.svelte.ts
+++ b/tools/server/webui/src/lib/stores/settings.svelte.ts
@@ -80,7 +80,8 @@ class SettingsStore {
if (!browser) return;
try {
- const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
+ const storedConfigRaw = localStorage.getItem('config');
+ const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
diff --git a/tools/server/webui/src/lib/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts
index d0e60a6c13706..6d76ab1f68e9d 100644
--- a/tools/server/webui/src/lib/types/api.d.ts
+++ b/tools/server/webui/src/lib/types/api.d.ts
@@ -36,6 +36,41 @@ export interface ApiChatMessageData {
timestamp?: number;
}
+export interface ApiModelDataEntry {
+ id: string;
+ object: string;
+ created: number;
+ owned_by: string;
+ meta?: Record | null;
+}
+
+export interface ApiModelDetails {
+ name: string;
+ model: string;
+ modified_at?: string;
+ size?: string | number;
+ digest?: string;
+ type?: string;
+ description?: string;
+ tags?: string[];
+ capabilities?: string[];
+ parameters?: string;
+ details?: {
+ parent_model?: string;
+ format?: string;
+ family?: string;
+ families?: string[];
+ parameter_size?: string;
+ quantization_level?: string;
+ };
+}
+
+export interface ApiModelListResponse {
+ object: string;
+ data: ApiModelDataEntry[];
+ models?: ApiModelDetails[];
+}
+
export interface ApiLlamaCppServerProps {
default_generation_settings: {
id: number;
@@ -120,6 +155,7 @@ export interface ApiChatCompletionRequest {
content: string | ApiChatMessageContentPart[];
}>;
stream?: boolean;
+ model?: string;
// Reasoning parameters
reasoning_format?: string;
// Generation parameters
@@ -150,10 +186,14 @@ export interface ApiChatCompletionRequest {
}
export interface ApiChatCompletionStreamChunk {
+ model?: string;
choices: Array<{
+ model?: string;
+ metadata?: { model?: string };
delta: {
content?: string;
reasoning_content?: string;
+ model?: string;
};
}>;
timings?: {
@@ -167,10 +207,14 @@ export interface ApiChatCompletionStreamChunk {
}
export interface ApiChatCompletionResponse {
+ model?: string;
choices: Array<{
+ model?: string;
+ metadata?: { model?: string };
message: {
content: string;
reasoning_content?: string;
+ model?: string;
};
}>;
}
diff --git a/tools/server/webui/src/lib/types/models.d.ts b/tools/server/webui/src/lib/types/models.d.ts
new file mode 100644
index 0000000000000..3b6bad5f0feae
--- /dev/null
+++ b/tools/server/webui/src/lib/types/models.d.ts
@@ -0,0 +1,11 @@
+import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
+
+export interface ModelOption {
+ id: string;
+ name: string;
+ model: string;
+ description?: string;
+ capabilities: string[];
+ details?: ApiModelDetails['details'];
+ meta?: ApiModelDataEntry['meta'];
+}
diff --git a/tools/server/webui/src/lib/types/settings.d.ts b/tools/server/webui/src/lib/types/settings.d.ts
index 4311f779ad841..659fb0c7d1cf5 100644
--- a/tools/server/webui/src/lib/types/settings.d.ts
+++ b/tools/server/webui/src/lib/types/settings.d.ts
@@ -41,6 +41,7 @@ export interface SettingsChatServiceOptions {
// Callbacks
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
+ onModel?: (model: string) => void;
onComplete?: (response: string, reasoningContent?: string, timings?: ChatMessageTimings) => void;
onError?: (error: Error) => void;
}
diff --git a/tools/server/webui/src/lib/utils/model-names.test.ts b/tools/server/webui/src/lib/utils/model-names.test.ts
new file mode 100644
index 0000000000000..e19e92f777092
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/model-names.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { isValidModelName, normalizeModelName } from './model-names';
+
+describe('normalizeModelName', () => {
+ it('extracts filename from forward slash path', () => {
+ expect(normalizeModelName('models/model-name-1')).toBe('model-name-1');
+ expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
+ });
+
+ it('extracts filename from backslash path', () => {
+ expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
+ expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
+ });
+
+ it('handles mixed path separators', () => {
+ expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2');
+ });
+
+ it('returns simple names as-is', () => {
+ expect(normalizeModelName('simple-model')).toBe('simple-model');
+ expect(normalizeModelName('model-name-2')).toBe('model-name-2');
+ });
+
+ it('trims whitespace', () => {
+ expect(normalizeModelName(' model-name ')).toBe('model-name');
+ });
+
+ it('returns empty string for empty input', () => {
+ expect(normalizeModelName('')).toBe('');
+ expect(normalizeModelName(' ')).toBe('');
+ });
+});
+
+describe('isValidModelName', () => {
+ it('returns true for valid names', () => {
+ expect(isValidModelName('model')).toBe(true);
+ expect(isValidModelName('path/to/model.bin')).toBe(true);
+ });
+
+ it('returns false for empty values', () => {
+ expect(isValidModelName('')).toBe(false);
+ expect(isValidModelName(' ')).toBe(false);
+ });
+});
diff --git a/tools/server/webui/src/lib/utils/model-names.ts b/tools/server/webui/src/lib/utils/model-names.ts
new file mode 100644
index 0000000000000..b1ea9d95361e6
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/model-names.ts
@@ -0,0 +1,39 @@
+/**
+ * Normalizes a model name by extracting the filename from a path.
+ *
+ * Handles both forward slashes (/) and backslashes (\) as path separators.
+ * If the model name is just a filename (no path), returns it as-is.
+ *
+ * @param modelName - The model name or path to normalize
+ * @returns The normalized model name (filename only)
+ *
+ * @example
+ * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b'
+ * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4'
+ * normalizeModelName('simple-model') // Returns: 'simple-model'
+ * normalizeModelName(' spaced ') // Returns: 'spaced'
+ * normalizeModelName('') // Returns: ''
+ */
+export function normalizeModelName(modelName: string): string {
+ const trimmed = modelName.trim();
+
+ if (!trimmed) {
+ return '';
+ }
+
+ const segments = trimmed.split(/[\\/]/);
+ const candidate = segments.pop();
+ const normalized = candidate?.trim();
+
+ return normalized && normalized.length > 0 ? normalized : trimmed;
+}
+
+/**
+ * Validates if a model name is valid (non-empty after normalization).
+ *
+ * @param modelName - The model name to validate
+ * @returns true if valid, false otherwise
+ */
+export function isValidModelName(modelName: string): boolean {
+ return normalizeModelName(modelName).length > 0;
+}
diff --git a/tools/server/webui/src/lib/utils/portal-to-body.ts b/tools/server/webui/src/lib/utils/portal-to-body.ts
new file mode 100644
index 0000000000000..bffbe89006948
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/portal-to-body.ts
@@ -0,0 +1,20 @@
+export function portalToBody(node: HTMLElement) {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const target = document.body;
+ if (!target) {
+ return;
+ }
+
+ target.appendChild(node);
+
+ return {
+ destroy() {
+ if (node.parentNode === target) {
+ target.removeChild(node);
+ }
+ }
+ };
+}
diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte
index 8912f642ceffc..075bdd356bc99 100644
--- a/tools/server/webui/src/routes/+layout.svelte
+++ b/tools/server/webui/src/routes/+layout.svelte
@@ -165,10 +165,10 @@