- 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]>
143 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|