- 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>
379 lines
10 KiB
Svelte
379 lines
10 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
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();
|
|
|
|
// Initialize accent (triggers subscriber which sets CSS vars)
|
|
// 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;
|
|
const res = await fetch('/api/push');
|
|
if (!res.ok) return;
|
|
const { publicKey } = await res.json();
|
|
const existing = await reg.pushManager.getSubscription();
|
|
const sub =
|
|
existing ??
|
|
(await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer
|
|
}));
|
|
await fetch('/api/push', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
endpoint: sub.endpoint,
|
|
keys: {
|
|
p256dh: arrayBufferToBase64(sub.getKey('p256dh')!),
|
|
auth: arrayBufferToBase64(sub.getKey('auth')!)
|
|
}
|
|
})
|
|
});
|
|
} catch (err) {
|
|
console.warn('[push] setup failed:', err);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => modelEs?.close());
|
|
|
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
|
const pad = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(b64);
|
|
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
}
|
|
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
}
|
|
|
|
// Derive active nav item from current path
|
|
const active = $derived.by(() => {
|
|
const path = $page.url.pathname;
|
|
if (path.startsWith('/jobs')) return 'queue';
|
|
if (path.startsWith('/settings')) return 'settings';
|
|
return 'home';
|
|
});
|
|
|
|
const navItems = [
|
|
{
|
|
k: 'home',
|
|
label: 'Home',
|
|
href: '/',
|
|
icon: 'M2 7L8 2.5L14 7V13.5H10V9.5H6V13.5H2V7Z'
|
|
},
|
|
{
|
|
k: 'queue',
|
|
label: 'Queue',
|
|
href: '/jobs',
|
|
icon: 'M2 4H14M2 8H14M2 12H10',
|
|
stroke: true
|
|
},
|
|
{
|
|
k: 'settings',
|
|
label: 'Settings',
|
|
href: '/settings',
|
|
icon: 'M8 5.5A2.5 2.5 0 1 1 8 10.5A2.5 2.5 0 0 1 8 5.5ZM8 1V3M8 13V15M3.5 3.5L4.9 4.9M11.1 11.1L12.5 12.5M1 8H3M13 8H15M3.5 12.5L4.9 11.1M11.1 4.9L12.5 3.5',
|
|
stroke: true
|
|
}
|
|
];
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
<link rel="manifest" href="/manifest.json" />
|
|
</svelte:head>
|
|
|
|
<div class="app-shell">
|
|
<!-- ── Rail nav (desktop) ─────────────────────────────── -->
|
|
<nav class="rail" aria-label="Main navigation">
|
|
<!-- Logo -->
|
|
<a href="/" class="logo" aria-label="Tonemark home">
|
|
<div class="logo-icon">
|
|
<svg width="16" height="16" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
|
<rect x="6" y="8" width="4" height="16" rx="2" fill="#0c0d10" />
|
|
<rect x="14" y="12" width="4" height="8" rx="2" fill="#0c0d10" />
|
|
<rect x="22" y="6" width="4" height="20" rx="2" fill="#0c0d10" />
|
|
</svg>
|
|
</div>
|
|
<span class="logo-name">Tonemark<span class="logo-dot">·</span></span>
|
|
</a>
|
|
|
|
<!-- Nav items -->
|
|
<div class="nav-items">
|
|
{#each navItems as item}
|
|
<a
|
|
href={item.href}
|
|
class="nav-item"
|
|
class:active={active === item.k}
|
|
aria-current={active === item.k ? 'page' : undefined}
|
|
>
|
|
{#if active === item.k}
|
|
<div class="nav-indicator" aria-hidden="true"></div>
|
|
{/if}
|
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d={item.icon}
|
|
fill={item.stroke ? 'none' : 'currentColor'}
|
|
stroke={item.stroke ? 'currentColor' : 'none'}
|
|
stroke-width="1.4"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
{item.label}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="rail-spacer"></div>
|
|
|
|
<!-- Status dot -->
|
|
<div class="status-pill">
|
|
<div
|
|
class="status-dot"
|
|
class:pulse={modelMeta.pulse}
|
|
style="background: {modelMeta.dot}"
|
|
></div>
|
|
<span>{modelMeta.label}</span>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- ── Main content ───────────────────────────────────── -->
|
|
<main class="main-content">
|
|
{@render children()}
|
|
</main>
|
|
|
|
<!-- ── Tab bar (mobile) ───────────────────────────────── -->
|
|
<nav class="tabbar" aria-label="Mobile navigation">
|
|
{#each navItems as item}
|
|
<a href={item.href} class="tab-item" class:active={active === item.k}>
|
|
<svg width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d={item.icon}
|
|
fill={item.stroke ? 'none' : 'currentColor'}
|
|
stroke={item.stroke ? 'currentColor' : 'none'}
|
|
stroke-width="1.4"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
<span>{item.label}</span>
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
</div>
|
|
|
|
<style>
|
|
.app-shell {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
/* ── Rail ─────────────────────────────────────────────── */
|
|
.rail {
|
|
width: var(--rail-width);
|
|
flex-shrink: 0;
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 24px 14px;
|
|
background: rgba(255, 255, 255, 0.015);
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 0 8px 28px;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
}
|
|
.logo-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
background: var(--accent);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.logo-name {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.logo-dot {
|
|
color: rgba(232, 233, 236, 0.4);
|
|
font-weight: 400;
|
|
}
|
|
|
|
.nav-items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 9px 12px;
|
|
border-radius: 8px;
|
|
font-size: 13.5px;
|
|
font-weight: 500;
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
position: relative;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.nav-item:hover {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--text);
|
|
}
|
|
.nav-item.active {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: #fff;
|
|
}
|
|
.nav-indicator {
|
|
position: absolute;
|
|
left: -14px;
|
|
top: 8px;
|
|
bottom: 8px;
|
|
width: 2px;
|
|
border-radius: 2px;
|
|
background: var(--accent);
|
|
}
|
|
|
|
.rail-spacer { flex: 1; }
|
|
|
|
.status-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border);
|
|
font-size: 11px;
|
|
font-family: var(--font-mono);
|
|
color: var(--text-muted);
|
|
}
|
|
.status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
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 ─────────────────────────────────────── */
|
|
.main-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* ── Tab bar (mobile only) ────────────────────────────── */
|
|
.tabbar {
|
|
display: none;
|
|
}
|
|
|
|
/* ── Responsive ───────────────────────────────────────── */
|
|
@media (max-width: 768px) {
|
|
.rail {
|
|
display: none;
|
|
}
|
|
.app-shell {
|
|
flex-direction: column;
|
|
padding-bottom: 72px;
|
|
}
|
|
.tabbar {
|
|
display: flex;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
border-top: 1px solid var(--border);
|
|
background: rgba(12, 13, 16, 0.9);
|
|
backdrop-filter: blur(20px);
|
|
padding: 10px 0 max(30px, env(safe-area-inset-bottom));
|
|
justify-content: space-around;
|
|
z-index: 100;
|
|
}
|
|
.tab-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: rgba(232, 233, 236, 0.4);
|
|
text-decoration: none;
|
|
transition: color 0.15s;
|
|
}
|
|
.tab-item.active {
|
|
color: var(--accent);
|
|
}
|
|
.tab-item span {
|
|
font-size: 9.5px;
|
|
font-family: var(--font-mono);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
}
|
|
</style>
|
|
|