From 1865cf083294c53a718a51570bdb64e42ac01084 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 18 Sep 2025 16:58:33 +0200 Subject: [PATCH 1/3] 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. --- .../app/chat/ChatSidebar/ChatSidebar.svelte | 3 + .../app/chat/ChatSidebar/ModelSelector.svelte | 175 ++++++++++++++ .../webui/src/lib/components/app/index.ts | 1 + tools/server/webui/src/lib/services/chat.ts | 6 + tools/server/webui/src/lib/services/models.ts | 22 ++ .../webui/src/lib/stores/chat.svelte.ts | 9 + .../webui/src/lib/stores/models.svelte.ts | 220 ++++++++++++++++++ tools/server/webui/src/lib/types/api.d.ts | 36 +++ 8 files changed, 472 insertions(+) create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatSidebar/ModelSelector.svelte create mode 100644 tools/server/webui/src/lib/services/models.ts create mode 100644 tools/server/webui/src/lib/stores/models.svelte.ts 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..42f88483d2121 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -332,12 +332,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..24f0a3d89e3b8 --- /dev/null +++ b/tools/server/webui/src/lib/stores/models.svelte.ts @@ -0,0 +1,220 @@ +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 ?? [])] + : []; + + return { + id: item.id, + name: details?.name || this.toDisplayName(item.id), + 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 From 0ff099bc7b3fdbcf1ba5a76ad400abf750c5f0bc Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 29 Sep 2025 17:49:10 +0200 Subject: [PATCH 2/3] feat: normalize model selector display names (base filename + OpenAI-Compat name) + move Toaster --- tools/server/webui/src/lib/stores/models.svelte.ts | 5 ++++- tools/server/webui/src/routes/+layout.svelte | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts index 24f0a3d89e3b8..967346fcc964a 100644 --- a/tools/server/webui/src/lib/stores/models.svelte.ts +++ b/tools/server/webui/src/lib/stores/models.svelte.ts @@ -82,10 +82,13 @@ class ModelsStore { 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: details?.name || this.toDisplayName(item.id), + name: displayName, model: details?.model || item.id, description: details?.description, capabilities: rawCapabilities.filter((value): value is string => Boolean(value)), 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 @@ - + From 3998da1221f40737bbe8a6be92f6fafdf55b7b1a Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 3 Oct 2025 11:53:48 +0200 Subject: [PATCH 3/3] fix: capture freshly selected model in chat store for accurate reply metadata --- tools/server/webui/src/lib/stores/chat.svelte.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 42f88483d2121..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) {