feat: model-on-demand lifecycle — retry on 503, live status pill, warming indicator

- whisper.ts: add getModelStatus(); fix submitJob() to retry on 503 using
  Retry-After header instead of throwing; optional onModelWaiting callback
  lets the pipeline surface model state to the UI during the wait
- pipeline.ts: pass onModelWaiting callback → emits model_warming SSE event
  so the job detail page can show 'Warming up model…' while waiting
- types.ts: add ModelStateTag union and ModelStatus interface
- api/model/status: GET route proxies whisper /model/status (falls back to
  {state:'unloaded'} if whisper unreachable)
- api/model/events: GET route relays whisper SSE stream to the browser;
  AbortController tied to request.signal cleans up on disconnect
- layout.svelte: status pill is now live — initial fetch + EventSource on
  /api/model/events; dot colour + label reflect real model state with a
  pulsing animation while loading or waiting_for_gpu
- jobs/[id]/+page.svelte: handle model_warming event type → show a yellow
  'Warming up model…' sub-label with spinner inside the progress card
- whisper.test.ts: update submitJob mocks to status:202 to match real API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giancarmine Salucci
2026-05-09 00:08:21 +02:00
parent ffd5d48c0d
commit b90d57984c
8 changed files with 201 additions and 19 deletions

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { accent } from '$lib/accent.js';
import type { ModelStatus } from '$lib/types.js';
let { children } = $props();
@@ -11,8 +12,43 @@
// The store subscriber handles everything; just subscribing here keeps it alive.
$effect(() => { void $accent; });
// ── Model status ───────────────────────────────────────
let modelStatus = $state<ModelStatus>({ state: 'unloaded' });
let modelEs: EventSource | null = null;
function refreshModelStatus() {
fetch('/api/model/status')
.then((r) => r.json())
.then((s) => (modelStatus = s as ModelStatus))
.catch(() => {});
}
function subscribeModelEvents() {
modelEs?.close();
modelEs = new EventSource('/api/model/events');
modelEs.addEventListener('model_loading', () => refreshModelStatus());
modelEs.addEventListener('model_ready', () => refreshModelStatus());
modelEs.addEventListener('model_unloaded', () => refreshModelStatus());
modelEs.addEventListener('model_waiting_for_gpu',() => refreshModelStatus());
modelEs.onerror = () => { /* browser reconnects automatically */ };
}
const modelStateMeta: Record<string, { dot: string; label: string; pulse: boolean }> = {
unloaded: { dot: 'var(--text-dim)', label: 'model unloaded', pulse: false },
loading: { dot: '#f0b429', label: 'model loading…', pulse: true },
waiting_for_gpu: { dot: '#f97316', label: 'waiting for GPU', pulse: true },
ready: { dot: '#5dd47a', label: 'whisper-large-v3',pulse: false }
};
const modelMeta = $derived(
modelStateMeta[modelStatus.state] ?? modelStateMeta.unloaded
);
// Push notification setup
onMount(async () => {
refreshModelStatus();
subscribeModelEvents();
if (!browser || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
try {
const reg = await navigator.serviceWorker.ready;
@@ -42,6 +78,8 @@
}
});
onDestroy(() => modelEs?.close());
function urlBase64ToUint8Array(base64: string): Uint8Array {
const pad = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
@@ -135,8 +173,12 @@
<!-- Status dot -->
<div class="status-pill">
<div class="status-dot"></div>
<span>whisper-large-v3</span>
<div
class="status-dot"
class:pulse={modelMeta.pulse}
style="background: {modelMeta.dot}"
></div>
<span>{modelMeta.label}</span>
</div>
</nav>
@@ -268,8 +310,15 @@
width: 6px;
height: 6px;
border-radius: 3px;
background: #5dd47a;
flex-shrink: 0;
transition: background 0.4s;
}
.status-dot.pulse {
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Main content ─────────────────────────────────────── */