+ {#if loading && options.length === 0 && isRouter}
-
+
Loading models…
- {:else if options.length === 0}
+ {:else if options.length === 0 && isRouter}
No models available.
{:else}
{@const selectedOption = getDisplayOption()}
-
+
- {#if isOpen}
+ {#if isOpen && isRouter}
handleOptionSelect(option.id)}
+ aria-selected={currentModel === option.model || activeId === option.id}
+ onclick={() => handleSelect(option.id)}
>
-
- {option.name}
-
-
- {#if option.description}
- {option.description}
- {/if}
+ {option.name}
{/each}
@@ -345,8 +326,8 @@
{/if}
{/if}
-
- {#if error}
-
{error}
- {/if}
+
+{#if showModelDialog && !isRouter}
+
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte b/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte
deleted file mode 100644
index 9a43e333c4902..0000000000000
--- a/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-{#if props}
-
- {#if model}
-
-
-
- {model}
-
- {/if}
-
-
- {#if props.default_generation_settings.n_ctx}
-
- ctx: {props.default_generation_settings.n_ctx.toLocaleString()}
-
- {/if}
-
- {#if modalities.length > 0}
- {#each modalities as modality (modality)}
-
- {#if modality === 'vision'}
-
- {:else if modality === 'audio'}
-
- {/if}
-
- {modality}
-
- {/each}
- {/if}
-
-
-{/if}
diff --git a/tools/server/webui/src/lib/components/ui/table/index.ts b/tools/server/webui/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000000000..99239aeead53e
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Root from './table.svelte';
+import Body from './table-body.svelte';
+import Caption from './table-caption.svelte';
+import Cell from './table-cell.svelte';
+import Footer from './table-footer.svelte';
+import Head from './table-head.svelte';
+import Header from './table-header.svelte';
+import Row from './table-row.svelte';
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow
+};
diff --git a/tools/server/webui/src/lib/components/ui/table/table-body.svelte b/tools/server/webui/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000000000..f8df65cf689b3
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-caption.svelte b/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000000000..0fdcc6439c1b3
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-cell.svelte b/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000000000..4506fdfc5bc3c
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/tools/server/webui/src/lib/components/ui/table/table-footer.svelte b/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000000000..77e4a64c08b23
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
tr]:last:border-b-0', className)}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-head.svelte b/tools/server/webui/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000000000..c1c57ad443495
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/tools/server/webui/src/lib/components/ui/table/table-header.svelte b/tools/server/webui/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000000000..eb366739b39e3
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-row.svelte b/tools/server/webui/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000000000..4131d3660a4c7
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+
svelte-css-wrapper]:[&>th,td]:bg-muted/50',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table.svelte b/tools/server/webui/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000000000..c11a6a6c4ba62
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/tools/server/webui/src/lib/enums/attachment.ts b/tools/server/webui/src/lib/enums/attachment.ts
new file mode 100644
index 0000000000000..7c7d0da994699
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/attachment.ts
@@ -0,0 +1,10 @@
+/**
+ * Attachment type enum for database message extras
+ */
+export enum AttachmentType {
+ AUDIO = 'AUDIO',
+ IMAGE = 'IMAGE',
+ PDF = 'PDF',
+ TEXT = 'TEXT',
+ LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
+}
diff --git a/tools/server/webui/src/lib/enums/model.ts b/tools/server/webui/src/lib/enums/model.ts
new file mode 100644
index 0000000000000..7729ecfeabb03
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/model.ts
@@ -0,0 +1,5 @@
+export enum ModelModality {
+ TEXT = 'TEXT',
+ AUDIO = 'AUDIO',
+ VISION = 'VISION'
+}
diff --git a/tools/server/webui/src/lib/enums/server.ts b/tools/server/webui/src/lib/enums/server.ts
new file mode 100644
index 0000000000000..f2d893537d34a
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/server.ts
@@ -0,0 +1,19 @@
+/**
+ * Server mode enum - used for single/multi-model mode
+ */
+export enum ServerMode {
+ /** Single model mode - server running with a specific model loaded */
+ MODEL = 'MODEL',
+ /** Router mode - server managing multiple model instances */
+ ROUTER = 'ROUTER'
+}
+
+/**
+ * Model status enum - matches tools/server/server-models.h from C++ server
+ */
+export enum ServerModelStatus {
+ UNLOADED = 'UNLOADED',
+ LOADING = 'LOADING',
+ LOADED = 'LOADED',
+ FAILED = 'FAILED'
+}
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
index aa83910b27f53..a0fc4a0f5d1bc 100644
--- a/tools/server/webui/src/lib/services/chat.ts
+++ b/tools/server/webui/src/lib/services/chat.ts
@@ -7,8 +7,10 @@ import type {
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
- ApiChatMessageData
+ ApiChatMessageData,
+ ApiModelListResponse
} from '$lib/types/api';
+import { AttachmentType } from '$lib/enums/attachment';
import type {
DatabaseMessage,
DatabaseMessageExtra,
@@ -74,7 +76,6 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
- onFirstValidChunk,
// Generation parameters
temperature,
max_tokens,
@@ -223,7 +224,6 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
- onFirstValidChunk,
conversationId,
abortController.signal
);
@@ -298,7 +298,6 @@ export class ChatService {
onReasoningChunk?: (chunk: string) => void,
onToolCallChunk?: (chunk: string) => void,
onModel?: (model: string) => void,
- onFirstValidChunk?: () => void,
conversationId?: string,
abortSignal?: AbortSignal
): Promise
{
@@ -315,7 +314,6 @@ export class ChatService {
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
let modelEmitted = false;
- let firstValidChunkEmitted = false;
let toolCallIndexOffset = 0;
let hasOpenToolCallBatch = false;
@@ -382,15 +380,6 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
-
- if (!firstValidChunkEmitted && parsed.object === 'chat.completion.chunk') {
- firstValidChunkEmitted = true;
-
- if (!abortSignal?.aborted) {
- onFirstValidChunk?.();
- }
- }
-
const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
@@ -618,7 +607,7 @@ export class ChatService {
const imageFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
- extra.type === 'imageFile'
+ extra.type === AttachmentType.IMAGE
);
for (const image of imageFiles) {
@@ -630,7 +619,7 @@ export class ChatService {
const textFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
- extra.type === 'textFile'
+ extra.type === AttachmentType.TEXT
);
for (const textFile of textFiles) {
@@ -643,7 +632,7 @@ export class ChatService {
// Handle legacy 'context' type from old webui (pasted content)
const legacyContextFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
- extra.type === 'context'
+ extra.type === AttachmentType.LEGACY_CONTEXT
);
for (const legacyContextFile of legacyContextFiles) {
@@ -655,7 +644,7 @@ export class ChatService {
const audioFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
- extra.type === 'audioFile'
+ extra.type === AttachmentType.AUDIO
);
for (const audio of audioFiles) {
@@ -670,7 +659,7 @@ export class ChatService {
const pdfFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
- extra.type === 'pdfFile'
+ extra.type === AttachmentType.PDF
);
for (const pdfFile of pdfFiles) {
@@ -722,6 +711,33 @@ export class ChatService {
}
}
+ /**
+ * Get model information from /models endpoint
+ */
+ static async getModels(): Promise {
+ try {
+ const currentConfig = config();
+ const apiKey = currentConfig.apiKey?.toString().trim();
+
+ const response = await fetch(`./models`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error('Error fetching models:', error);
+ throw error;
+ }
+ }
+
/**
* Aborts any ongoing chat completion request.
* Cancels the current request and cleans up the abort controller.
diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts
index c70b9580cb75b..028ada3287194 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -1,7 +1,6 @@
import { DatabaseStore } from '$lib/stores/database';
import { chatService, slotsService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
-import { serverStore } from '$lib/stores/server.svelte';
import { normalizeModelName } from '$lib/utils/model-names';
import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
import { browser } from '$app/environment';
@@ -365,41 +364,15 @@ class ChatStore {
let resolvedModel: string | null = null;
let modelPersisted = false;
- const currentConfig = config();
- const preferServerPropsModel = !currentConfig.modelSelectorEnabled;
- let serverPropsRefreshed = false;
- let updateModelFromServerProps: ((persistImmediately?: boolean) => void) | null = null;
-
- const refreshServerPropsOnce = () => {
- if (serverPropsRefreshed) {
- return;
- }
-
- serverPropsRefreshed = true;
-
- const hasExistingProps = serverStore.serverProps !== null;
-
- serverStore
- .fetchServerProps({ silent: hasExistingProps })
- .then(() => {
- updateModelFromServerProps?.(true);
- })
- .catch((error) => {
- console.warn('Failed to refresh server props after streaming started:', error);
- });
- };
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
- const serverModelName = serverStore.modelName;
- const preferredModelSource = preferServerPropsModel
- ? (serverModelName ?? modelName ?? null)
- : (modelName ?? serverModelName ?? null);
-
- if (!preferredModelSource) {
+ if (!modelName) {
return;
}
- const normalizedModel = normalizeModelName(preferredModelSource);
+ const normalizedModel = normalizeModelName(modelName);
+
+ console.log('Resolved model:', normalizedModel);
if (!normalizedModel || normalizedModel === resolvedModel) {
return;
@@ -423,20 +396,6 @@ class ChatStore {
}
};
- if (preferServerPropsModel) {
- updateModelFromServerProps = (persistImmediately = true) => {
- const currentServerModel = serverStore.modelName;
-
- if (!currentServerModel) {
- return;
- }
-
- recordModel(currentServerModel, persistImmediately);
- };
-
- updateModelFromServerProps(false);
- }
-
slotsService.startStreaming();
slotsService.setActiveConversation(assistantMessage.convId);
@@ -445,9 +404,6 @@ class ChatStore {
{
...this.getApiOptions(),
- onFirstValidChunk: () => {
- refreshServerPropsOnce();
- },
onChunk: (chunk: string) => {
streamedContent += chunk;
this.setConversationStreaming(
diff --git a/tools/server/webui/src/lib/stores/server.svelte.ts b/tools/server/webui/src/lib/stores/server.svelte.ts
index e95c0bcea2f9e..b8d47f295f1cb 100644
--- a/tools/server/webui/src/lib/stores/server.svelte.ts
+++ b/tools/server/webui/src/lib/stores/server.svelte.ts
@@ -2,6 +2,9 @@ import { browser } from '$app/environment';
import { SERVER_PROPS_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
import { ChatService } from '$lib/services/chat';
import { config } from '$lib/stores/settings.svelte';
+import { ServerMode } from '$lib/enums/server';
+import { ModelModality } from '$lib/enums/model';
+import { updateConfig } from '$lib/stores/settings.svelte';
/**
* ServerStore - Server state management and capability detection
@@ -52,6 +55,10 @@ class ServerStore {
private _error = $state(null);
private _serverWarning = $state(null);
private _slotsEndpointAvailable = $state(null);
+ private _serverMode = $state(null);
+ private _selectedModel = $state(null);
+ private _availableModels = $state([]);
+ private _modelLoadingStates = $state