From 8464f0e49298fb9b33b2c8688498ab838ef88565 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 14 Jul 2026 20:35:03 +0200 Subject: [PATCH] Better model download management --- docs/Architecture.md | 7 +++++-- docs/Decisions.md | 18 ++++++++++++++++++ docs/WorkBreakdown.md | 5 +++++ src/App.tsx | 21 +++++++++++++++++---- src/components/ModelPicker.tsx | 19 ++++++++++++++++++- src/index.css | 23 +++++++++++++++++++++++ 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/docs/Architecture.md b/docs/Architecture.md index d822182..bfb3734 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -107,8 +107,11 @@ are the remaining M5 work; see `WorkBreakdown.md`.) ### 4.1 Browser transcription (anonymous, default) 1. User picks a model in `ModelPicker` (curated catalog from `lib/models.ts`, with - size estimates and a Cache-API-backed "downloaded" badge); UI posts - `{type:"load", modelId, device}` where `device` is a preference + size estimates and a Cache-API-backed "downloaded" badge). Selection alone + downloads nothing (D-020): the UI posts `{type:"load", modelId, device}` when a + browser-engine transcription starts (file drop / live start) or when the user + clicks the explicit **Download** button on the selected model; server-engine + runs never post a load. `device` is a preference (`"auto" | "webnn" | "webgpu" | "wasm"`, default `"auto"`). An explicit `dtype` override is expressible in the message but normally omitted. 2. Worker resolves the device (D-004, D-017): for `"auto"` it probes diff --git a/docs/Decisions.md b/docs/Decisions.md index a84670b..1e0e81b 100644 --- a/docs/Decisions.md +++ b/docs/Decisions.md @@ -272,3 +272,21 @@ and its loaded pipeline are left untouched in that path. **Why:** M7 future want. Enables logged-in users on weak devices to transcribe live microphone audio using the server engine. **Shape:** The client opens a WebSocket connection to `/api/transcribe/live`. The client streams 16kHz Float32 PCM chunks (from `MicCapture`) over the WebSocket. The server runs `LiveTranscriber` and `LiveSegmenter` (from `src/lib/live.ts`, which is pure and runs perfectly in Node/Bun). For each segment/pass, the server's `LiveTranscriber` writes the audio to a temporary file and invokes the Python sidecar. The server sends `LiveUpdate` objects back to the client over the WebSocket. **Consequence:** The client does zero ML and minimal buffering, acting only as a mic-streamer and UI-renderer for live passes on the server. + +## D-020 — Model downloads are user-initiated, never a side effect of selection +**Status:** Accepted (2026-07-14) +Selecting a model in `ModelPicker` (or changing the backend preference) no longer +starts the weight download. Weights load exactly when they are needed or asked for: +- **Lazy:** a file drop or live start on the **browser** engine posts the + (idempotent) load, as both paths already did — the download overlaps decoding / + mic startup. +- **Explicit:** a **Download** button on the selected, not-yet-cached model in + `ModelPicker` starts the load immediately — this keeps D-005's "prepare for + offline use" story alive now that selection alone caches nothing. +- **Server engine:** server runs never touch the browser pipeline, so with the + toggle on "server" nothing downloads. (Previously the eager selection load + downloaded browser weights even when every transcription would run server-side.) +**Consequence:** model/backend switches just reset the load UI state +(`loadStarted`, progress); the next load starts from clean progress. The explicit +download is a full pipeline load, so it also warms up inference and surfaces the +"Running on …" backend badge. diff --git a/docs/WorkBreakdown.md b/docs/WorkBreakdown.md index f00c950..0e4ed7b 100644 --- a/docs/WorkBreakdown.md +++ b/docs/WorkBreakdown.md @@ -242,6 +242,11 @@ Notes (M5): models, versioned/cleaned by the SW itself) is deliberately not surfaced — deleting it per-model makes no sense and the SW would just re-fetch it. +- [x] Model downloads are user-initiated (D-020): selecting a model/backend no + longer auto-downloads weights; the load starts on browser-engine + transcription start (file drop / live start) or via an explicit + per-model Download button in `ModelPicker` — and never when the engine + toggle is set to "server". - [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript) - [ ] Production build + `bun start` deployment check; README for deployers diff --git a/src/App.tsx b/src/App.tsx index 8a876bb..e448c9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -283,16 +283,27 @@ export function App() { setPartialText(""); setTranscribeProgress(null); setResult(null); - startLoad(nextModelId, devicePreference); + // Selection never downloads (D-020): the load starts when a transcription + // needs it (file drop / live start on the browser engine) or when the + // user explicitly clicks Download. Reset the load state so the next load + // reports fresh progress for this model. + setLoadStarted(false); + setLoadProgress(initialLoadProgress); } function handleDevicePreference(preference: DevicePreference) { if (busy || liveStatus !== "idle" || preference === devicePreference) return; setDevicePreference(preference); setActiveDevice(null); - // Only reload eagerly if a load was already in motion; otherwise the next - // file drop picks up the new preference. - if (loadStarted) startLoad(modelId, preference); + // No eager reload (D-020) — the next load picks up the new preference. + setLoadStarted(false); + setLoadProgress(initialLoadProgress); + } + + /** Explicit model download (D-020) — pre-caches weights for offline use. */ + function handleDownloadModel() { + if (busy || liveStatus !== "idle" || loadStarted) return; + startLoad(modelId, devicePreference); } /** @@ -631,6 +642,8 @@ export function App() { downloaded={downloaded} offlineReady={swControlled} disabled={busy || liveActive} + onDownloadSelected={handleDownloadModel} + downloading={loadStarted && !modelReady} /> {model?.multilingual && ( diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index a51db1f..3771972 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -19,6 +19,10 @@ interface ModelPickerProps { offlineReady: boolean; /** True while a transcription is running — switching is disabled then. */ disabled?: boolean; + /** Explicitly download the selected model's weights (D-020). */ + onDownloadSelected: () => void; + /** True while a model load/download is in flight. */ + downloading: boolean; } const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [ @@ -50,6 +54,8 @@ export function ModelPicker({ downloaded, offlineReady, disabled = false, + onDownloadSelected, + downloading, }: ModelPickerProps) { // Size estimates depend on the dtype the worker will pick, which depends on // the device. Until the worker has reported one, assume WASM unless the @@ -116,7 +122,7 @@ export function ModelPicker({ {offlineReady ? downloaded.size > 0 ? "Offline ready — the app and the models marked “available offline” work without a network connection." - : "The app is cached for offline use. Load a model once to make it available offline too." + : "The app is cached for offline use. Download a model (or run one on-device transcription) to make it available offline too." : "Offline support is not active yet (the service worker is still installing, or this browser does not support it)."}

{groups.map((group) => ( @@ -153,6 +159,17 @@ export function ModelPicker({ )} ~{size} + {isSelected && !isDownloaded && ( + + )} diff --git a/src/index.css b/src/index.css index 907be60..10eff78 100644 --- a/src/index.css +++ b/src/index.css @@ -306,6 +306,29 @@ body { white-space: nowrap; } +/* Explicit weight download for the selected model (D-020). */ +.model-download-button { + border: 1px solid var(--border); + background: transparent; + color: inherit; + border-radius: 999px; + padding: 0.1rem 0.7rem; + font-size: 0.75rem; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.model-download-button:hover:enabled { + border-color: #3d6b4f; + color: #9ad6ae; +} + +.model-download-button:disabled { + opacity: 0.6; + cursor: default; +} + /* --- PWA / offline (M3) --- */ .offline-status { margin: 0.5rem 0 0.75rem;