diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
index 5976e5dd03d7b..adf9f880ae670 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
@@ -13,6 +13,7 @@
updateConversationName
} from '$lib/stores/chat.svelte';
import ChatSidebarActions from './ChatSidebarActions.svelte';
+ import ModelSelector from './ModelSelector.svelte';
const sidebar = Sidebar.useSidebar();
@@ -110,6 +111,8 @@
llama.cpp
+
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ModelSelector.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ModelSelector.svelte
new file mode 100644
index 0000000000000..48f3632da6835
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ModelSelector.svelte
@@ -0,0 +1,175 @@
+
+
+
+
+
Model selector
+
+
+
+
+ {#if loading && options.length === 0 && !isMounted}
+
+
+ Loading models…
+
+ {:else if options.length === 0}
+
No models available.
+ {:else}
+ {@const selectedOption = getDisplayOption()}
+
+
+
+ {selectedOption?.name || 'Select model'}
+
+ {#if updating}
+
+ {/if}
+
+
+
+ {#each options as option (option.id)}
+
+
+
{option.name}
+
+ {#if option.description}
+
{option.description}
+ {/if}
+
+ {#if option.capabilities.length > 0}
+
+ {#each option.capabilities as capability (capability)}
+
+ {getCapabilityLabel(capability)}
+
+ {/each}
+
+ {/if}
+
+
+ {/each}
+
+
+
+ {#if selectedOption?.capabilities.length}
+
+ {#each selectedOption.capabilities as capability (capability)}
+
+ {getCapabilityLabel(capability)}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts
index 7c1af27ecd3fd..023186ad5e364 100644
--- a/tools/server/webui/src/lib/components/app/index.ts
+++ b/tools/server/webui/src/lib/components/app/index.ts
@@ -29,6 +29,7 @@ export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsF
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 ChatSidebarModelSelector } from './chat/ChatSidebar/ModelSelector.svelte';
export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
index 369cdf4e8b935..2f89595cc2b83 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
@@ -117,6 +118,11 @@ export class ChatService {
stream
};
+ const activeModel = selectedModelName();
+ if (activeModel) {
+ requestBody.model = activeModel;
+ }
+
requestBody.reasoning_format = 'auto';
if (temperature !== undefined) requestBody.temperature = temperature;
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 e7f59d0117d08..76085c6ddfb15 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -1,6 +1,7 @@
import { DatabaseStore } from '$lib/stores/database';
import { chatService, slotsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte';
+import { selectedModelName } from '$lib/stores/models.svelte';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
import { browser } from '$app/environment';
@@ -312,7 +313,7 @@ class ChatStore {
const captureModelIfNeeded = (updateDbImmediately = true): string | undefined => {
if (!modelCaptured) {
- const currentModelName = serverStore.modelName;
+ const currentModelName = selectedModelName() ?? serverStore.modelName;
if (currentModelName) {
if (updateDbImmediately) {
@@ -332,12 +333,21 @@ class ChatStore {
return undefined;
};
+ let hasSyncedServerProps = false;
+
slotsService.startStreaming();
await chatService.sendMessage(allMessages, {
...this.getApiOptions(),
onChunk: (chunk: string) => {
+ if (!hasSyncedServerProps) {
+ hasSyncedServerProps = true;
+ void serverStore.fetchServerProps().catch((error) => {
+ console.warn('Failed to refresh server props after first chunk:', error);
+ });
+ }
+
streamedContent += chunk;
this.currentResponse = streamedContent;
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..967346fcc964a
--- /dev/null
+++ b/tools/server/webui/src/lib/stores/models.svelte.ts
@@ -0,0 +1,223 @@
+import { browser } from '$app/environment';
+import { ModelsService } from '$lib/services/models';
+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'];
+}
+
+type PersistedModelSelection = {
+ id: string;
+ model: string;
+};
+
+const STORAGE_KEY = 'llama.cpp:selectedModel';
+
+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);
+
+ constructor() {
+ const persisted = this.readPersistedSelection();
+ 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 persisted = this.readPersistedSelection();
+ let nextSelectionId = this._selectedModelId ?? persisted?.id ?? null;
+ let nextSelectionName = this._selectedModelName ?? persisted?.model ?? null;
+ if (nextSelectionId) {
+ const match = models.find((model) => model.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;
+ }
+
+ this._selectedModelId = nextSelectionId;
+ this._selectedModelName = nextSelectionName;
+ this.persistSelection(
+ nextSelectionId && nextSelectionName
+ ? { id: nextSelectionId, model: nextSelectionName }
+ : 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.persistSelection({ 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;
+ }
+
+ private readPersistedSelection(): PersistedModelSelection | null {
+ if (!browser) {
+ return null;
+ }
+
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed.id === 'string') {
+ const id = parsed.id;
+ const model =
+ typeof parsed.model === 'string' && parsed.model.length > 0 ? parsed.model : id;
+ return { id, model };
+ }
+ } catch (error) {
+ console.warn('Failed to read model selection from localStorage:', error);
+ }
+
+ return null;
+ }
+
+ private persistSelection(selection: PersistedModelSelection | null): void {
+ if (!browser) {
+ return;
+ }
+
+ try {
+ if (selection) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(selection));
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ } catch (error) {
+ console.warn('Failed to persist model selection to localStorage:', error);
+ }
+ }
+}
+
+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/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts
index d0e60a6c13706..027ce213c35f1 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
diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte
index bc204291960ce..bcac572a2f53d 100644
--- a/tools/server/webui/src/routes/+layout.svelte
+++ b/tools/server/webui/src/routes/+layout.svelte
@@ -143,7 +143,7 @@
-
+