Files
whisper_web/src/components/ModelPicker.tsx
T
Lucas WintherandClaude Fable 5 9f39a82982 WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper
- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 04:02:28 +02:00

143 lines
5.0 KiB
TypeScript

import {
estimateDefaultDownloadBytes,
formatModelSize,
type WhisperModel,
} from "../lib/models";
import type { DevicePreference, InferenceDevice } from "../worker/protocol";
interface ModelPickerProps {
models: readonly WhisperModel[];
selectedId: string;
onSelect: (modelId: string) => void;
devicePreference: DevicePreference;
onDevicePreferenceChange: (preference: DevicePreference) => void;
/** Backend the worker actually selected; null until the first load (D-004). */
activeDevice: InferenceDevice | null;
/** Model ids with weights already in the browser cache (D-005). */
downloaded: ReadonlySet<string>;
/** True when the service worker controls the page — app shell cached (M3). */
offlineReady: boolean;
/** True while a transcription is running — switching is disabled then. */
disabled?: boolean;
}
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
{ value: "auto", label: "Auto (WebGPU if available)" },
{ value: "webgpu", label: "WebGPU" },
{ value: "wasm", label: "WASM (CPU)" },
];
/**
* Curated model list (D-006): size estimate before download, English-only vs
* multilingual grouping, downloaded badge from the Cache API (D-005), and a
* device preference select (default "auto", D-004).
*/
export function ModelPicker({
models,
selectedId,
onSelect,
devicePreference,
onDevicePreferenceChange,
activeDevice,
downloaded,
offlineReady,
disabled = false,
}: 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
// user explicitly asked for WebGPU.
const estimateDevice: InferenceDevice =
activeDevice ?? (devicePreference === "webgpu" ? "webgpu" : "wasm");
const groups: { title: string; items: WhisperModel[] }[] = [
{
title: "English-only",
items: models.filter((model) => !model.multilingual),
},
{
title: "Multilingual",
items: models.filter((model) => model.multilingual),
},
];
return (
<section className="model-picker" aria-label="Model selection">
<div className="model-picker-header">
<h2>Model</h2>
<label className="device-select">
Backend
<select
value={devicePreference}
disabled={disabled}
onChange={(event) =>
onDevicePreferenceChange(event.target.value as DevicePreference)
}
>
{DEVICE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{disabled && (
<p className="model-picker-hint">
Model switching is disabled while a transcription is running.
</p>
)}
<p
className={`offline-status${offlineReady && downloaded.size > 0 ? " offline-status-ready" : ""}`}
>
{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."
: "Offline support is not active yet (the service worker is still installing, or this browser does not support it)."}
</p>
{groups.map((group) => (
<fieldset key={group.title} className="model-group" disabled={disabled}>
<legend>{group.title}</legend>
<ul>
{group.items.map((model) => {
const size = formatModelSize(
estimateDefaultDownloadBytes(model, estimateDevice),
);
const isDownloaded = downloaded.has(model.id);
const isSelected = model.id === selectedId;
return (
<li key={model.id}>
<label
className={`model-option${isSelected ? " model-option-selected" : ""}`}
>
<input
type="radio"
name="model"
value={model.id}
checked={isSelected}
disabled={disabled}
onChange={() => onSelect(model.id)}
/>
<span className="model-option-name">{model.displayName}</span>
<span className="model-option-meta">
{isDownloaded && (
<span
className="model-badge"
title="Weights are cached in this browser — this model loads and transcribes without a network connection."
>
available offline
</span>
)}
<span className="model-option-size">~{size}</span>
</span>
</label>
</li>
);
})}
</ul>
</fieldset>
))}
</section>
);
}