ui: model status and load progress via /models/sse feed (#24878)
* 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
This commit is contained in:
Vendored
+10
@@ -19,6 +19,10 @@ import type {
|
|||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ApiLlamaCppServerProps,
|
ApiLlamaCppServerProps,
|
||||||
ApiModelDataEntry,
|
ApiModelDataEntry,
|
||||||
|
ApiModelLoadStage,
|
||||||
|
ApiModelsSseProgress,
|
||||||
|
ApiModelsSseData,
|
||||||
|
ApiModelsSseEvent,
|
||||||
ApiModelListResponse,
|
ApiModelListResponse,
|
||||||
ApiProcessingState,
|
ApiProcessingState,
|
||||||
ApiRouterModelMeta,
|
ApiRouterModelMeta,
|
||||||
@@ -52,6 +56,7 @@ import type {
|
|||||||
// Model types
|
// Model types
|
||||||
ModelModalities,
|
ModelModalities,
|
||||||
ModelOption,
|
ModelOption,
|
||||||
|
ModelLoadProgress,
|
||||||
// Settings types
|
// Settings types
|
||||||
SettingsChatServiceOptions,
|
SettingsChatServiceOptions,
|
||||||
SettingsConfigValue,
|
SettingsConfigValue,
|
||||||
@@ -83,6 +88,10 @@ declare global {
|
|||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ApiLlamaCppServerProps,
|
ApiLlamaCppServerProps,
|
||||||
ApiModelDataEntry,
|
ApiModelDataEntry,
|
||||||
|
ApiModelLoadStage,
|
||||||
|
ApiModelsSseProgress,
|
||||||
|
ApiModelsSseData,
|
||||||
|
ApiModelsSseEvent,
|
||||||
ApiModelListResponse,
|
ApiModelListResponse,
|
||||||
ApiProcessingState,
|
ApiProcessingState,
|
||||||
ApiRouterModelMeta,
|
ApiRouterModelMeta,
|
||||||
@@ -120,6 +129,7 @@ declare global {
|
|||||||
// Model types
|
// Model types
|
||||||
ModelModalities,
|
ModelModalities,
|
||||||
ModelOption,
|
ModelOption,
|
||||||
|
ModelLoadProgress,
|
||||||
// Settings types
|
// Settings types
|
||||||
SettingsChatServiceOptions,
|
SettingsChatServiceOptions,
|
||||||
SettingsConfigValue,
|
SettingsConfigValue,
|
||||||
|
|||||||
+12
-3
@@ -10,7 +10,7 @@
|
|||||||
import { getMessageEditContext } from '$lib/contexts';
|
import { getMessageEditContext } from '$lib/contexts';
|
||||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.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 { AgenticSectionType } from '$lib/enums';
|
||||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
import { REASONING_TAGS } from '$lib/constants/agentic';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
@@ -185,6 +185,13 @@
|
|||||||
let hasNoContent = $derived(!message?.content?.trim());
|
let hasNoContent = $derived(!message?.content?.trim());
|
||||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
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(
|
let showProcessingInfoTop = $derived(
|
||||||
message?.role === MessageRole.ASSISTANT &&
|
message?.role === MessageRole.ASSISTANT &&
|
||||||
isActivelyProcessing &&
|
isActivelyProcessing &&
|
||||||
@@ -220,7 +227,8 @@
|
|||||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||||
<div class="processing-container">
|
<div class="processing-container">
|
||||||
<span class="processing-text">
|
<span class="processing-text">
|
||||||
{processingState.getPromptProgressText() ??
|
{modelLoadingText ??
|
||||||
|
processingState.getPromptProgressText() ??
|
||||||
processingState.getProcessingMessage() ??
|
processingState.getProcessingMessage() ??
|
||||||
'Processing...'}
|
'Processing...'}
|
||||||
</span>
|
</span>
|
||||||
@@ -252,7 +260,8 @@
|
|||||||
<div class="mt-4 w-full max-w-[48rem]" in:fade>
|
<div class="mt-4 w-full max-w-[48rem]" in:fade>
|
||||||
<div class="processing-container">
|
<div class="processing-container">
|
||||||
<span class="processing-text">
|
<span class="processing-text">
|
||||||
{processingState.getPromptProgressText() ??
|
{modelLoadingText ??
|
||||||
|
processingState.getPromptProgressText() ??
|
||||||
processingState.getProcessingMessage() ??
|
processingState.getProcessingMessage() ??
|
||||||
'Processing...'}
|
'Processing...'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import type { ModelOption } from '$lib/types/models';
|
import type { ModelOption } from '$lib/types/models';
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
||||||
|
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
option: ModelOption;
|
option: ModelOption;
|
||||||
@@ -50,11 +51,15 @@
|
|||||||
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
|
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
|
||||||
);
|
);
|
||||||
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={[
|
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',
|
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||||
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
||||||
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
||||||
@@ -62,6 +67,7 @@
|
|||||||
]}
|
]}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected || isHighlighted}
|
aria-selected={isSelected || isHighlighted}
|
||||||
|
title={loadTitle}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={() => onSelect(option.id)}
|
onclick={() => onSelect(option.id)}
|
||||||
onmouseenter={onMouseEnter}
|
onmouseenter={onMouseEnter}
|
||||||
@@ -188,4 +194,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export const API_MODELS = {
|
export const API_MODELS = {
|
||||||
LIST: '/v1/models',
|
LIST: '/v1/models',
|
||||||
LOAD: '/models/load',
|
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)
|
// chat completion routes, the control route drives realtime inference (e.g. end reasoning)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export * from './mcp-form';
|
|||||||
export * from './mcp-resource';
|
export * from './mcp-resource';
|
||||||
export * from './message-export';
|
export * from './message-export';
|
||||||
export * from './model-id';
|
export * from './model-id';
|
||||||
|
export * from './model-loading';
|
||||||
|
export * from './sse';
|
||||||
export * from './precision';
|
export * from './precision';
|
||||||
export * from './processing-info';
|
export * from './processing-info';
|
||||||
export * from './pwa';
|
export * from './pwa';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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]';
|
||||||
@@ -54,7 +54,7 @@ export {
|
|||||||
|
|
||||||
export { ModelModality } from './model.enums';
|
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';
|
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,17 @@ export enum ServerModelStatus {
|
|||||||
SLEEPING = 'sleeping',
|
SLEEPING = 'sleeping',
|
||||||
FAILED = 'failed'
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
API_CHAT,
|
API_CHAT,
|
||||||
API_SLOTS,
|
API_SLOTS,
|
||||||
CONTROL_ACTION
|
CONTROL_ACTION,
|
||||||
|
SSE_LINE_SEPARATOR,
|
||||||
|
SSE_DATA_PREFIX,
|
||||||
|
SSE_DONE_MARKER
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import {
|
import {
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
@@ -18,8 +21,7 @@ import {
|
|||||||
FileTypeAudio,
|
FileTypeAudio,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
MimeTypeAudio,
|
MimeTypeAudio,
|
||||||
ReasoningFormat,
|
ReasoningFormat
|
||||||
UrlProtocol
|
|
||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import type {
|
import type {
|
||||||
ApiChatMessageContentPart,
|
ApiChatMessageContentPart,
|
||||||
@@ -642,15 +644,15 @@ export class ChatService {
|
|||||||
if (abortSignal?.aborted) break;
|
if (abortSignal?.aborted) break;
|
||||||
|
|
||||||
chunk += decoder.decode(value, { stream: true });
|
chunk += decoder.decode(value, { stream: true });
|
||||||
const lines = chunk.split('\n');
|
const lines = chunk.split(SSE_LINE_SEPARATOR);
|
||||||
chunk = lines.pop() || '';
|
chunk = lines.pop() || '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (abortSignal?.aborted) break;
|
if (abortSignal?.aborted) break;
|
||||||
|
|
||||||
if (line.startsWith(UrlProtocol.DATA)) {
|
if (line.startsWith(SSE_DATA_PREFIX)) {
|
||||||
const data = line.slice(6);
|
const data = line.slice(SSE_DATA_PREFIX.length).trim();
|
||||||
if (data === '[DONE]') {
|
if (data === SSE_DONE_MARKER) {
|
||||||
streamFinished = true;
|
streamFinished = true;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { base } from '$app/paths';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
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 { ModelsService } from '$lib/services/models.service';
|
||||||
import { PropsService } from '$lib/services/props.service';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||||
@@ -8,11 +9,15 @@ import {
|
|||||||
detectThinkingSupport,
|
detectThinkingSupport,
|
||||||
detectThinkingSupportWithReason
|
detectThinkingSupportWithReason
|
||||||
} from '$lib/utils/chat-template-thinking-detector';
|
} from '$lib/utils/chat-template-thinking-detector';
|
||||||
import { TTLCache } from '$lib/utils';
|
import { TTLCache, getAuthHeaders } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
MODEL_PROPS_CACHE_TTL_MS,
|
MODEL_PROPS_CACHE_TTL_MS,
|
||||||
MODEL_PROPS_CACHE_MAX_ENTRIES,
|
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';
|
} from '$lib/constants';
|
||||||
|
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
@@ -55,6 +60,15 @@ class ModelsStore {
|
|||||||
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
||||||
private modelLoadingStates = new SvelteMap<string, boolean>();
|
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());
|
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
|
||||||
* WORKAROUND: Polling for model status after load/unload operations.
|
private static readonly SSE_RECONNECT_MS = 1000;
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
private static readonly STATUS_POLL_INTERVAL = 500;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll for expected model status after load/unload operation.
|
* Open the /models/sse feed and keep it live with auto reconnect.
|
||||||
* Keeps polling until the model reaches the expected status or fails.
|
* Idempotent and router mode only. The feed drives status and progress,
|
||||||
|
* so it replaces any post-operation polling.
|
||||||
*/
|
*/
|
||||||
private async pollForModelStatus(
|
subscribeStatus(): void {
|
||||||
modelId: string,
|
if (this.statusReaderActive) return;
|
||||||
expectedStatus: ServerModelStatus
|
if (!isRouterMode()) return;
|
||||||
): Promise<void> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
await this.fetchRouterModels();
|
|
||||||
|
|
||||||
const currentStatus = this.getModelStatus(modelId);
|
this.statusReaderActive = true;
|
||||||
if (currentStatus === expectedStatus) return;
|
this.statusAbort = new AbortController();
|
||||||
|
void this.runStatusReader(this.statusAbort.signal);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentStatus === ServerModelStatus.FAILED) {
|
/**
|
||||||
throw new Error(
|
* Close the /models/sse feed and drop transient progress.
|
||||||
`Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
|
*/
|
||||||
);
|
unsubscribeStatus(): void {
|
||||||
|
this.statusReaderActive = false;
|
||||||
|
this.statusAbort?.abort();
|
||||||
|
this.statusAbort = null;
|
||||||
|
this.loadProgress.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current load progress for a model, or null when not loading.
|
||||||
|
*/
|
||||||
|
getLoadProgress(modelId: string): ModelLoadProgress | null {
|
||||||
|
return this.loadProgress.get(modelId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
if (signal.aborted) return;
|
||||||
expectedStatus === ServerModelStatus.LOADED &&
|
|
||||||
currentStatus === ServerModelStatus.UNLOADED &&
|
|
||||||
attempt > 2
|
|
||||||
) {
|
|
||||||
throw new Error('Model was unloaded unexpectedly during loading');
|
|
||||||
}
|
|
||||||
|
|
||||||
attempt++;
|
await new Promise((resolve) => setTimeout(resolve, ModelsStore.SSE_RECONNECT_MS));
|
||||||
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
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.modelLoadingStates.set(modelId, true);
|
||||||
this.error = null;
|
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 {
|
try {
|
||||||
await ModelsService.load(modelId);
|
await ModelsService.load(modelId);
|
||||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
await reachedLoaded;
|
||||||
await this.updateModelModalities(modelId);
|
|
||||||
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.rejectStatus(modelId, error instanceof Error ? error : new Error('load failed'));
|
||||||
this.error = error instanceof Error ? error.message : 'Failed to load model';
|
this.error = error instanceof Error ? error.message : 'Failed to load model';
|
||||||
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
|
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -700,11 +889,17 @@ class ModelsStore {
|
|||||||
this.modelLoadingStates.set(modelId, true);
|
this.modelLoadingStates.set(modelId, true);
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
|
this.subscribeStatus();
|
||||||
|
|
||||||
|
const reachedUnloaded = this.waitForStatus(modelId, ServerModelStatus.UNLOADED);
|
||||||
|
reachedUnloaded.catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ModelsService.unload(modelId);
|
await ModelsService.unload(modelId);
|
||||||
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
await reachedUnloaded;
|
||||||
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.rejectStatus(modelId, error instanceof Error ? error : new Error('unload failed'));
|
||||||
this.error = error instanceof Error ? error.message : 'Failed to unload model';
|
this.error = error instanceof Error ? error.message : 'Failed to unload model';
|
||||||
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
|
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -783,6 +978,9 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
this.unsubscribeStatus();
|
||||||
|
this.statusWaiters.forEach((waiter) => waiter.reject(new Error('Models store cleared')));
|
||||||
|
this.statusWaiters.clear();
|
||||||
this.models = [];
|
this.models = [];
|
||||||
this.routerModels = [];
|
this.routerModels = [];
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|||||||
Vendored
+47
-1
@@ -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';
|
import type { ChatMessagePromptProgress, ChatRole } from './chat';
|
||||||
|
|
||||||
export type AudioInputFormat = FileTypeAudio.WAV | FileTypeAudio.MP3;
|
export type AudioInputFormat = FileTypeAudio.WAV | FileTypeAudio.MP3;
|
||||||
@@ -96,6 +102,46 @@ export interface ApiModelDataEntry {
|
|||||||
meta?: Record<string, unknown> | null;
|
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 {
|
export interface ApiModelDetails {
|
||||||
name: string;
|
name: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export type {
|
|||||||
ApiChatMessageData,
|
ApiChatMessageData,
|
||||||
ApiModelStatus,
|
ApiModelStatus,
|
||||||
ApiModelDataEntry,
|
ApiModelDataEntry,
|
||||||
|
ApiModelLoadStage,
|
||||||
|
ApiModelsSseProgress,
|
||||||
|
ApiModelsSseData,
|
||||||
|
ApiModelsSseEvent,
|
||||||
ApiModelDetails,
|
ApiModelDetails,
|
||||||
ApiModelListResponse,
|
ApiModelListResponse,
|
||||||
ApiLlamaCppServerProps,
|
ApiLlamaCppServerProps,
|
||||||
@@ -70,7 +74,12 @@ export type {
|
|||||||
} from './database';
|
} from './database';
|
||||||
|
|
||||||
// Model types
|
// Model types
|
||||||
export type { ModelModalities, ModelOption, ModalityCapabilities } from './models';
|
export type {
|
||||||
|
ModelModalities,
|
||||||
|
ModelOption,
|
||||||
|
ModelLoadProgress,
|
||||||
|
ModalityCapabilities
|
||||||
|
} from './models';
|
||||||
|
|
||||||
// Settings types
|
// Settings types
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Vendored
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
|
import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';
|
||||||
|
|
||||||
export interface ModelModalities {
|
export interface ModelModalities {
|
||||||
vision: boolean;
|
vision: boolean;
|
||||||
@@ -20,6 +20,17 @@ export interface ModelOption {
|
|||||||
tags?: string[];
|
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 {
|
export interface ParsedModelId {
|
||||||
raw: string;
|
raw: string;
|
||||||
orgName: string | null;
|
orgName: string | null;
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
|
|||||||
// URL utilities
|
// URL utilities
|
||||||
export { extractRootDomain, sanitizeExternalUrl } from './url';
|
export { extractRootDomain, sanitizeExternalUrl } from './url';
|
||||||
|
|
||||||
|
// Progress helpers
|
||||||
|
export { modelLoadFraction, modelLoadProgressText } from './progress';
|
||||||
|
|
||||||
// Conversation utilities
|
// Conversation utilities
|
||||||
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
||||||
|
|
||||||
|
|||||||
@@ -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)}%`;
|
||||||
|
}
|
||||||
@@ -230,6 +230,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Background MCP server health checks on app load
|
||||||
// Fetch enabled servers from settings and run health checks in background
|
// Fetch enabled servers from settings and run health checks in background
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user