]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: model status and load progress via /models/sse feed (#24878)
authorPascal <redacted>
Mon, 22 Jun 2026 13:55:30 +0000 (15:55 +0200)
committerGitHub <redacted>
Mon, 22 Jun 2026 13:55:30 +0000 (15:55 +0200)
* ui: model status and load progress via /models/sse feed

* ui: centralize SSE wire-format delimiters into shared constants for the chat and /models/sse parsers

* ui: type /models/sse event names as a ServerModelsSseEventType enum

Address review from allozaur

17 files changed:
tools/ui/src/app.d.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
tools/ui/src/lib/constants/api-endpoints.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/model-loading.ts [new file with mode: 0644]
tools/ui/src/lib/constants/sse.ts [new file with mode: 0644]
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/server.enums.ts
tools/ui/src/lib/services/chat.service.ts
tools/ui/src/lib/stores/models.svelte.ts
tools/ui/src/lib/types/api.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/models.d.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/progress.ts [new file with mode: 0644]
tools/ui/src/routes/+layout.svelte

index a7583eec59ae4d14b9e215c45f567265a8756055..5264e5cc4d72b8e00f83fc221b73a1e78a1a1656 100644 (file)
@@ -19,6 +19,10 @@ import type {
        ApiErrorResponse,
        ApiLlamaCppServerProps,
        ApiModelDataEntry,
+       ApiModelLoadStage,
+       ApiModelsSseProgress,
+       ApiModelsSseData,
+       ApiModelsSseEvent,
        ApiModelListResponse,
        ApiProcessingState,
        ApiRouterModelMeta,
@@ -52,6 +56,7 @@ import type {
        // Model types
        ModelModalities,
        ModelOption,
+       ModelLoadProgress,
        // Settings types
        SettingsChatServiceOptions,
        SettingsConfigValue,
@@ -83,6 +88,10 @@ declare global {
                ApiErrorResponse,
                ApiLlamaCppServerProps,
                ApiModelDataEntry,
+               ApiModelLoadStage,
+               ApiModelsSseProgress,
+               ApiModelsSseData,
+               ApiModelsSseEvent,
                ApiModelListResponse,
                ApiProcessingState,
                ApiRouterModelMeta,
@@ -120,6 +129,7 @@ declare global {
                // Model types
                ModelModalities,
                ModelOption,
+               ModelLoadProgress,
                // Settings types
                SettingsChatServiceOptions,
                SettingsConfigValue,
index 4c74206f1beec9da0eeefbaa5ed2f064271ea5e1..2272eaedb397ea8a67abf123e5c1177bec034c20 100644 (file)
@@ -10,7 +10,7 @@
        import { getMessageEditContext } from '$lib/contexts';
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
-       import { copyToClipboard, deriveAgenticSections } from '$lib/utils';
+       import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
        import { AgenticSectionType } from '$lib/enums';
        import { REASONING_TAGS } from '$lib/constants/agentic';
        import { tick } from 'svelte';
        let hasNoContent = $derived(!message?.content?.trim());
        let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
 
+       // during a router auto-load the message has no model yet, so target the selected one
+       let loadTargetModel = $derived(message.model ?? modelsStore.selectedModelName);
+       let modelLoadProgress = $derived(
+               isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
+       );
+       let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
+
        let showProcessingInfoTop = $derived(
                message?.role === MessageRole.ASSISTANT &&
                        isActivelyProcessing &&
                <div class="mt-6 w-full max-w-[48rem]" in:fade>
                        <div class="processing-container">
                                <span class="processing-text">
-                                       {processingState.getPromptProgressText() ??
+                                       {modelLoadingText ??
+                                               processingState.getPromptProgressText() ??
                                                processingState.getProcessingMessage() ??
                                                'Processing...'}
                                </span>
                <div class="mt-4 w-full max-w-[48rem]" in:fade>
                        <div class="processing-container">
                                <span class="processing-text">
-                                       {processingState.getPromptProgressText() ??
+                                       {modelLoadingText ??
+                                               processingState.getPromptProgressText() ??
                                                processingState.getProcessingMessage() ??
                                                'Processing...'}
                                </span>
index fef1490f3741ebfa2753ff50ef02aa7af13193da..f2a024d31d031e54e83524904fe06acbe207fbee 100644 (file)
@@ -13,6 +13,7 @@
        import type { ModelOption } from '$lib/types/models';
        import { ServerModelStatus } from '$lib/enums';
        import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+       import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
 
        interface Props {
                option: ModelOption;
                (serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
        );
        let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
+
+       let loadProgress = $derived(isLoading ? modelsStore.getLoadProgress(option.model) : null);
+       let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
+       let loadTitle = $derived(modelLoadProgressText(loadProgress));
 </script>
 
 <div
        class={[
-               'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
+               'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
                'cursor-pointer hover:bg-muted focus:bg-muted',
                (isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
                !(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
@@ -62,6 +67,7 @@
        ]}
        role="option"
        aria-selected={isSelected || isHighlighted}
+       title={loadTitle}
        tabindex="0"
        onclick={() => onSelect(option.id)}
        onmouseenter={onMouseEnter}
                        </div>
                {/if}
        </div>
+
+       {#if isLoading}
+               <div
+                       class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm bg-muted"
+               >
+                       <div
+                               class="h-full bg-primary transition-[width] duration-200 ease-out"
+                               style="width: {loadPercent}%"
+                       ></div>
+               </div>
+       {/if}
 </div>
index 9eb6c74e75f61669da500f6eeb36913148a76a99..a41090505756b03ccf5a9def076c98f8c6d5838d 100644 (file)
@@ -1,7 +1,8 @@
 export const API_MODELS = {
        LIST: '/v1/models',
        LOAD: '/models/load',
-       UNLOAD: '/models/unload'
+       UNLOAD: '/models/unload',
+       SSE: '/models/sse'
 };
 
 // chat completion routes, the control route drives realtime inference (e.g. end reasoning)
index c51d84cdc2e32291a240321a14ec5dc26fc05445..4993ab647ad21181ce80a62e57c9d411e582c8c7 100644 (file)
@@ -37,6 +37,8 @@ export * from './mcp-form';
 export * from './mcp-resource';
 export * from './message-export';
 export * from './model-id';
+export * from './model-loading';
+export * from './sse';
 export * from './precision';
 export * from './processing-info';
 export * from './pwa';
diff --git a/tools/ui/src/lib/constants/model-loading.ts b/tools/ui/src/lib/constants/model-loading.ts
new file mode 100644 (file)
index 0000000..a55ba70
--- /dev/null
@@ -0,0 +1,14 @@
+/**
+ * Labels shown while a model loads, keyed by the stage reported on /models/sse.
+ */
+export const MODEL_LOAD_STAGE_LABELS: Record<ApiModelLoadStage, string> = {
+       text_model: 'Loading weights',
+       spec_model: 'Loading draft',
+       mmproj_model: 'Loading projector'
+};
+
+/**
+ * Share of the bar reserved for each load phase after text_model.
+ * text_model fills the rest, so a plain model reaches 100% on its own.
+ */
+export const MODEL_LOAD_TAIL_SHARE = 0.1;
diff --git a/tools/ui/src/lib/constants/sse.ts b/tools/ui/src/lib/constants/sse.ts
new file mode 100644 (file)
index 0000000..0eb4b6e
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * Server-sent events wire format, shared by the chat stream and the
+ * /models/sse status feed (text/event-stream).
+ */
+
+// blank line between two events
+export const SSE_RECORD_SEPARATOR = '\n\n';
+
+// line break inside an event
+export const SSE_LINE_SEPARATOR = '\n';
+
+// data field prefix, the value follows after an optional space
+export const SSE_DATA_PREFIX = 'data:';
+
+// end-of-stream marker on the chat completion stream
+export const SSE_DONE_MARKER = '[DONE]';
index 449e4f90a9981307274ded5727dcbd709f66d13e..811744fd9a7c811022d7e9653bfe8dc35a644dc4 100644 (file)
@@ -54,7 +54,7 @@ export {
 
 export { ModelModality } from './model.enums';
 
-export { ServerRole, ServerModelStatus } from './server.enums';
+export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
 
 export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';
 
index c9d599c52bfa7ad04ff459222b79ed8a506a8041..446af84be7089e620004bf9b7b52e44f4cbfe3a0 100644 (file)
@@ -19,3 +19,17 @@ export enum ServerModelStatus {
        SLEEPING = 'sleeping',
        FAILED = 'failed'
 }
+
+/**
+ * /models/sse event type enum - discriminates the records broadcast on the
+ * model status feed in ROUTER mode. Matches the event names emitted by
+ * tools/server/server-models.cpp from the C++ server.
+ */
+export enum ServerModelsSseEventType {
+       STATUS_CHANGE = 'status_change',
+       MODEL_STATUS = 'model_status',
+       STATUS_UPDATE = 'status_update',
+       MODELS_RELOAD = 'models_reload',
+       MODEL_REMOVE = 'model_remove',
+       DOWNLOAD_PROGRESS = 'download_progress'
+}
index 70844f57ee42970063dad5770bcd620fbb343f7d..9001c9572fea2786a9bf7d6a65688f9f5d071bc5 100644 (file)
@@ -10,7 +10,10 @@ import {
        SETTINGS_KEYS,
        API_CHAT,
        API_SLOTS,
-       CONTROL_ACTION
+       CONTROL_ACTION,
+       SSE_LINE_SEPARATOR,
+       SSE_DATA_PREFIX,
+       SSE_DONE_MARKER
 } from '$lib/constants';
 import {
        AttachmentType,
@@ -18,8 +21,7 @@ import {
        FileTypeAudio,
        MessageRole,
        MimeTypeAudio,
-       ReasoningFormat,
-       UrlProtocol
+       ReasoningFormat
 } from '$lib/enums';
 import type {
        ApiChatMessageContentPart,
@@ -642,15 +644,15 @@ export class ChatService {
                                if (abortSignal?.aborted) break;
 
                                chunk += decoder.decode(value, { stream: true });
-                               const lines = chunk.split('\n');
+                               const lines = chunk.split(SSE_LINE_SEPARATOR);
                                chunk = lines.pop() || '';
 
                                for (const line of lines) {
                                        if (abortSignal?.aborted) break;
 
-                                       if (line.startsWith(UrlProtocol.DATA)) {
-                                               const data = line.slice(6);
-                                               if (data === '[DONE]') {
+                                       if (line.startsWith(SSE_DATA_PREFIX)) {
+                                               const data = line.slice(SSE_DATA_PREFIX.length).trim();
+                                               if (data === SSE_DONE_MARKER) {
                                                        streamFinished = true;
 
                                                        continue;
index 1990ba6049de69f43e7b1afa597b50f6ff3b2bf3..2ce450d4233b1cb11528ffb84e4d8902fd405389 100644 (file)
@@ -1,6 +1,7 @@
+import { base } from '$app/paths';
 import { SvelteMap, SvelteSet } from 'svelte/reactivity';
 import { toast } from 'svelte-sonner';
-import { ServerModelStatus, ModelModality } from '$lib/enums';
+import { ServerModelStatus, ServerModelsSseEventType, ModelModality } from '$lib/enums';
 import { ModelsService } from '$lib/services/models.service';
 import { PropsService } from '$lib/services/props.service';
 import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
@@ -8,11 +9,15 @@ import {
        detectThinkingSupport,
        detectThinkingSupportWithReason
 } from '$lib/utils/chat-template-thinking-detector';
-import { TTLCache } from '$lib/utils';
+import { TTLCache, getAuthHeaders } from '$lib/utils';
 import {
        MODEL_PROPS_CACHE_TTL_MS,
        MODEL_PROPS_CACHE_MAX_ENTRIES,
-       FAVORITE_MODELS_LOCALSTORAGE_KEY
+       FAVORITE_MODELS_LOCALSTORAGE_KEY,
+       API_MODELS,
+       SSE_RECORD_SEPARATOR,
+       SSE_LINE_SEPARATOR,
+       SSE_DATA_PREFIX
 } from '$lib/constants';
 
 import { conversationsStore } from '$lib/stores/conversations.svelte';
@@ -55,6 +60,15 @@ class ModelsStore {
        private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
        private modelLoadingStates = new SvelteMap<string, boolean>();
 
+       // /models/sse feed state, the single source of truth for status and load progress
+       private statusAbort: AbortController | null = null;
+       private statusReaderActive = false;
+       private loadProgress = new SvelteMap<string, ModelLoadProgress>();
+       private statusWaiters = new Map<
+               string,
+               { target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
+       >();
+
        favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
 
        /**
@@ -626,49 +640,218 @@ class ModelsStore {
         *
         */
 
+       // reconnect delay after the feed drops or the server is not ready yet
+       private static readonly SSE_RECONNECT_MS = 1000;
+
        /**
-        * WORKAROUND: Polling for model status after load/unload operations.
-        *
-        * Currently, `/models/load` and `/models/unload` return success before
-        * the operation actually completes on the server.
-        *
-        * TODO: Remove polling once llama-server properly waits for the operation
-        * to complete before returning success.
+        * Open the /models/sse feed and keep it live with auto reconnect.
+        * Idempotent and router mode only. The feed drives status and progress,
+        * so it replaces any post-operation polling.
         */
+       subscribeStatus(): void {
+               if (this.statusReaderActive) return;
+               if (!isRouterMode()) return;
 
-       private static readonly STATUS_POLL_INTERVAL = 500;
+               this.statusReaderActive = true;
+               this.statusAbort = new AbortController();
+               void this.runStatusReader(this.statusAbort.signal);
+       }
 
        /**
-        * Poll for expected model status after load/unload operation.
-        * Keeps polling until the model reaches the expected status or fails.
+        * Close the /models/sse feed and drop transient progress.
         */
-       private async pollForModelStatus(
-               modelId: string,
-               expectedStatus: ServerModelStatus
-       ): Promise<void> {
-               let attempt = 0;
-               while (true) {
-                       await this.fetchRouterModels();
+       unsubscribeStatus(): void {
+               this.statusReaderActive = false;
+               this.statusAbort?.abort();
+               this.statusAbort = null;
+               this.loadProgress.clear();
+       }
 
-                       const currentStatus = this.getModelStatus(modelId);
-                       if (currentStatus === expectedStatus) return;
+       /**
+        * Current load progress for a model, or null when not loading.
+        */
+       getLoadProgress(modelId: string): ModelLoadProgress | null {
+               return this.loadProgress.get(modelId) ?? null;
+       }
 
-                       if (currentStatus === ServerModelStatus.FAILED) {
-                               throw new Error(
-                                       `Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
-                               );
+       /**
+        * Read the feed and reconnect until unsubscribed. Splits the byte stream
+        * into SSE records on the blank line boundary.
+        */
+       private async runStatusReader(signal: AbortSignal): Promise<void> {
+               const decoder = new TextDecoder();
+
+               while (!signal.aborted) {
+                       try {
+                               const response = await fetch(`${base}${API_MODELS.SSE}`, {
+                                       headers: getAuthHeaders(),
+                                       signal
+                               });
+
+                               if (response.ok && response.body) {
+                                       const reader = response.body.getReader();
+                                       let buffer = '';
+
+                                       while (!signal.aborted) {
+                                               const { value, done } = await reader.read();
+                                               if (done) break;
+
+                                               buffer += decoder.decode(value, { stream: true });
+
+                                               let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
+                                               while (boundary !== -1) {
+                                                       this.handleStatusRecord(buffer.slice(0, boundary));
+                                                       buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
+                                                       boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
+                                               }
+                                       }
+                               }
+                       } catch {
+                               // network drop or abort falls through to the reconnect delay
                        }
 
-                       if (
-                               expectedStatus === ServerModelStatus.LOADED &&
-                               currentStatus === ServerModelStatus.UNLOADED &&
-                               attempt > 2
-                       ) {
-                               throw new Error('Model was unloaded unexpectedly during loading');
-                       }
+                       if (signal.aborted) return;
+
+                       await new Promise((resolve) => setTimeout(resolve, ModelsStore.SSE_RECONNECT_MS));
+               }
+       }
+
+       /**
+        * Parse one SSE record. The payload rides in the data lines as a JSON
+        * envelope that carries its own model, event and data fields.
+        */
+       private handleStatusRecord(record: string): void {
+               const payload = record
+                       .split(SSE_LINE_SEPARATOR)
+                       .filter((line) => line.startsWith(SSE_DATA_PREFIX))
+                       .map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
+                       .join(SSE_LINE_SEPARATOR);
+
+               if (payload.length === 0) return;
+
+               let envelope: ApiModelsSseEvent;
+               try {
+                       envelope = JSON.parse(payload);
+               } catch {
+                       return;
+               }
+
+               this.applyStatusEvent(envelope);
+       }
+
+       /**
+        * Route one feed record by event kind. Only the status_* events carry a
+        * status payload, models_reload triggers a list refresh, model_remove drops
+        * the row, download_* belong to the download surface, not here.
+        */
+       private applyStatusEvent(event: ApiModelsSseEvent): void {
+               switch (event.event) {
+                       case ServerModelsSseEventType.STATUS_CHANGE:
+                       case ServerModelsSseEventType.MODEL_STATUS:
+                       case ServerModelsSseEventType.STATUS_UPDATE:
+                               this.applyModelStatus(event);
+                               break;
+                       case ServerModelsSseEventType.MODELS_RELOAD:
+                               void this.fetchRouterModels();
+                               break;
+                       case ServerModelsSseEventType.MODEL_REMOVE:
+                               this.removeRouterModel(event.model);
+                               break;
+                       case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
+                               break;
+               }
+       }
+
+       /**
+        * Apply a status envelope: update the model row, track or clear progress,
+        * settle any pending load or unload awaiter.
+        */
+       private applyModelStatus(event: ApiModelsSseEvent): void {
+               const model = event.model;
+               const data = event.data;
+               if (!model || !data?.status) return;
+
+               const status = data.status;
+
+               this.setRouterModelStatus(model, status);
 
-                       attempt++;
-                       await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
+               if (status === ServerModelStatus.LOADING) {
+                       if (data.progress) this.loadProgress.set(model, data.progress);
+               } else {
+                       this.loadProgress.delete(model);
+               }
+
+               if (status === ServerModelStatus.LOADED) {
+                       void this.updateModelModalities(model);
+               }
+
+               const failed =
+                       status === ServerModelStatus.FAILED ||
+                       (status === ServerModelStatus.UNLOADED && (data.exit_code ?? 0) !== 0);
+
+               if (failed) {
+                       this.rejectStatus(model, new Error(`Model failed: ${this.toDisplayName(model)}`));
+                       return;
+               }
+
+               this.settleStatus(model, status);
+       }
+
+       /**
+        * Drop a model row reported gone by the feed and settle its awaiters.
+        */
+       private removeRouterModel(modelId: string): void {
+               if (this.routerModels.findIndex((m) => m.id === modelId) === -1) return;
+
+               this.routerModels = this.routerModels.filter((m) => m.id !== modelId);
+               this.loadProgress.delete(modelId);
+               this.rejectStatus(modelId, new Error(`Model removed: ${this.toDisplayName(modelId)}`));
+       }
+
+       /**
+        * Update one model row status in place, reassigning to trigger reactivity.
+        */
+       private setRouterModelStatus(modelId: string, status: ServerModelStatus): void {
+               const idx = this.routerModels.findIndex((m) => m.id === modelId);
+               if (idx === -1) return;
+
+               const current = this.routerModels[idx];
+               if (current.status.value === status) return;
+
+               const next = [...this.routerModels];
+               next[idx] = { ...current, status: { ...current.status, value: status } };
+               this.routerModels = next;
+       }
+
+       /**
+        * Register an awaiter that resolves when the feed reports target status.
+        * One operation runs per model at a time, so one awaiter per model is kept.
+        */
+       private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
+               return new Promise((resolve, reject) => {
+                       this.statusWaiters.set(modelId, { target, resolve, reject });
+               });
+       }
+
+       /**
+        * Resolve and drop the awaiter when the model reaches its target status.
+        */
+       private settleStatus(modelId: string, status: ServerModelStatus): void {
+               const waiter = this.statusWaiters.get(modelId);
+               if (waiter && waiter.target === status) {
+                       this.statusWaiters.delete(modelId);
+                       waiter.resolve();
+               }
+       }
+
+       /**
+        * Reject and drop the awaiter for a model.
+        */
+       private rejectStatus(modelId: string, error: Error): void {
+               const waiter = this.statusWaiters.get(modelId);
+               if (waiter) {
+                       this.statusWaiters.delete(modelId);
+                       waiter.reject(error);
                }
        }
 
@@ -679,12 +862,18 @@ class ModelsStore {
                this.modelLoadingStates.set(modelId, true);
                this.error = null;
 
+               // the feed drives completion, so it must be live before the request
+               this.subscribeStatus();
+
+               const reachedLoaded = this.waitForStatus(modelId, ServerModelStatus.LOADED);
+               reachedLoaded.catch(() => {});
+
                try {
                        await ModelsService.load(modelId);
-                       await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
-                       await this.updateModelModalities(modelId);
+                       await reachedLoaded;
                        toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
+                       this.rejectStatus(modelId, error instanceof Error ? error : new Error('load failed'));
                        this.error = error instanceof Error ? error.message : 'Failed to load model';
                        toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
                        throw error;
@@ -700,11 +889,17 @@ class ModelsStore {
                this.modelLoadingStates.set(modelId, true);
                this.error = null;
 
+               this.subscribeStatus();
+
+               const reachedUnloaded = this.waitForStatus(modelId, ServerModelStatus.UNLOADED);
+               reachedUnloaded.catch(() => {});
+
                try {
                        await ModelsService.unload(modelId);
-                       await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
+                       await reachedUnloaded;
                        toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
+                       this.rejectStatus(modelId, error instanceof Error ? error : new Error('unload failed'));
                        this.error = error instanceof Error ? error.message : 'Failed to unload model';
                        toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
                        throw error;
@@ -783,6 +978,9 @@ class ModelsStore {
        }
 
        clear(): void {
+               this.unsubscribeStatus();
+               this.statusWaiters.forEach((waiter) => waiter.reject(new Error('Models store cleared')));
+               this.statusWaiters.clear();
                this.models = [];
                this.routerModels = [];
                this.loading = false;
index f620d67351ba341da96c816276c85f27d4301644..2a2524d0023745a492f47ddc4bffba49f0428919 100644 (file)
@@ -1,4 +1,10 @@
-import type { ContentPartType, FileTypeAudio, ServerModelStatus, ServerRole } from '$lib/enums';
+import type {
+       ContentPartType,
+       FileTypeAudio,
+       ServerModelStatus,
+       ServerModelsSseEventType,
+       ServerRole
+} from '$lib/enums';
 import type { ChatMessagePromptProgress, ChatRole } from './chat';
 
 export type AudioInputFormat = FileTypeAudio.WAV | FileTypeAudio.MP3;
@@ -96,6 +102,46 @@ export interface ApiModelDataEntry {
        meta?: Record<string, unknown> | null;
 }
 
+/**
+ * Load stage reported by the /models/sse feed, in load order.
+ */
+export type ApiModelLoadStage = 'text_model' | 'spec_model' | 'mmproj_model';
+
+/**
+ * Load progress snapshot: the full ordered stage plan, the active stage,
+ * and its fractional value (0.0 -> 1.0).
+ */
+export interface ApiModelsSseProgress {
+       stages: ApiModelLoadStage[];
+       current: ApiModelLoadStage;
+       value: number;
+}
+
+/**
+ * Status payload carried by a /models/sse envelope.
+ * exit_code appears on unload.
+ */
+export interface ApiModelsSseData {
+       status: ServerModelStatus;
+       progress?: ApiModelsSseProgress;
+       exit_code?: number;
+}
+
+/**
+ * Event kind multiplexed on the /models/sse feed.
+ * Only the status_* events carry a status payload, models_reload signals a
+ * full list refresh, model_remove drops a row, download_* drive download UI.
+ */
+/**
+ * One /models/sse record. event discriminates the kind, model names the
+ * target instance, data carries the status payload when present.
+ */
+export interface ApiModelsSseEvent {
+       model: string;
+       event: ServerModelsSseEventType;
+       data: ApiModelsSseData;
+}
+
 export interface ApiModelDetails {
        name: string;
        model: string;
index c5f948898165e6670fcea0348af73b0691230024..9b0b118045a4ab1cf17277903f14e9be2cfd775d 100644 (file)
@@ -11,6 +11,10 @@ export type {
        ApiChatMessageData,
        ApiModelStatus,
        ApiModelDataEntry,
+       ApiModelLoadStage,
+       ApiModelsSseProgress,
+       ApiModelsSseData,
+       ApiModelsSseEvent,
        ApiModelDetails,
        ApiModelListResponse,
        ApiLlamaCppServerProps,
@@ -70,7 +74,12 @@ export type {
 } from './database';
 
 // Model types
-export type { ModelModalities, ModelOption, ModalityCapabilities } from './models';
+export type {
+       ModelModalities,
+       ModelOption,
+       ModelLoadProgress,
+       ModalityCapabilities
+} from './models';
 
 // Settings types
 export type {
index 51069599d7fc0557259f44cf62ea410deb8fa825..b32c16f6f26b518a8dce63a1275bc885bd7d9462 100644 (file)
@@ -1,4 +1,4 @@
-import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
+import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';
 
 export interface ModelModalities {
        vision: boolean;
@@ -20,6 +20,17 @@ export interface ModelOption {
        tags?: string[];
 }
 
+/**
+ * Ephemeral UI-only load progress for one model instance.
+ * Lives only while a load runs, driven by the /models/sse feed.
+ * stage is absent until the feed reports its first stage.
+ */
+export interface ModelLoadProgress {
+       stages: ApiModelLoadStage[];
+       current: ApiModelLoadStage;
+       value: number;
+}
+
 export interface ParsedModelId {
        raw: string;
        orgName: string | null;
index 637db8812c4dfd475220fe5b160bed987103fe20..61b9932d3f981fe7dfa22e316805e8317ae3579b 100644 (file)
@@ -44,6 +44,9 @@ export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
 // URL utilities
 export { extractRootDomain, sanitizeExternalUrl } from './url';
 
+// Progress helpers
+export { modelLoadFraction, modelLoadProgressText } from './progress';
+
 // Conversation utilities
 export { createMessageCountMap, getMessageCount } from './conversation-utils';
 
diff --git a/tools/ui/src/lib/utils/progress.ts b/tools/ui/src/lib/utils/progress.ts
new file mode 100644 (file)
index 0000000..4d7e223
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * Model load progress helpers for the /models/sse surfaces
+ * (selector row and chat message).
+ */
+
+import { MODEL_LOAD_STAGE_LABELS, MODEL_LOAD_TAIL_SHARE } from '$lib/constants';
+
+/**
+ * Human label for a model load stage.
+ */
+export function modelLoadStageLabel(stage: ApiModelLoadStage): string {
+       return MODEL_LOAD_STAGE_LABELS[stage];
+}
+
+/**
+ * Overall load fraction (0.0 -> 1.0) across the declared stage plan.
+ * text_model fills [0, 1 - tail], each later phase owns one tail slice.
+ */
+export function modelLoadFraction(progress: ModelLoadProgress | null): number {
+       if (!progress) return 0;
+
+       const { stages, current, value } = progress;
+       const tailCount = Math.max(stages.length - 1, 0);
+       const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
+       const idx = stages.indexOf(current);
+
+       if (idx <= 0) {
+               return value * textCeiling;
+       }
+
+       return textCeiling + (idx - 1 + value) * MODEL_LOAD_TAIL_SHARE;
+}
+
+/**
+ * Single line describing load progress: active stage label and overall percent.
+ * Returns null when there is no progress to show.
+ */
+export function modelLoadProgressText(progress: ModelLoadProgress | null): string | null {
+       if (!progress) return null;
+
+       const label = modelLoadStageLabel(progress.current);
+       return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
+}
index fdba9a9d37015985c9780a441592fe254cc23c8b..1269692a78cd37265219edf71e75aa0b82311848 100644 (file)
                }
        });
 
+       // Live model status and load progress via the /models/sse feed (router mode)
+       $effect(() => {
+               if (!browser) return;
+               if (!isRouterMode()) return;
+
+               untrack(() => {
+                       modelsStore.subscribeStatus();
+               });
+
+               return () => {
+                       modelsStore.unsubscribeStatus();
+               };
+       });
+
        // Background MCP server health checks on app load
        // Fetch enabled servers from settings and run health checks in background
        $effect(() => {