Files
au_whisper_web/src/components/ModelPicker.tsx
T

184 lines
6.7 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;
/** 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 }[] = [
{ value: "auto", label: "Auto (WebNN → WebGPU → WASM)" },
{ value: "webgpu", label: "WebGPU" },
{ value: "webnn", label: "WebNN" },
{ value: "wasm", label: "WASM (CPU)" },
];
/** Human-readable backend names, shared with the active-device badge. */
export const DEVICE_LABELS: Record<InferenceDevice, string> = {
webnn: "WebNN",
webgpu: "WebGPU",
wasm: "WASM",
};
/**
* 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,
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
// user explicitly asked for WebGPU.
const estimateDevice: InferenceDevice =
activeDevice ??
(devicePreference === "webgpu"
? "webgpu"
: devicePreference === "webnn"
? "webnn"
: "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>
{/* Which backend the worker actually selected (D-004/D-017) — the
user's preference is only a preference, so show the outcome. */}
{activeDevice && (
<span
className="model-badge device-active-badge"
role="status"
title="The inference backend the worker actually selected for this model."
>
Running on {DEVICE_LABELS[activeDevice]}
</span>
)}
</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. 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)."}
</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>
{isSelected && !isDownloaded && (
<button
type="button"
className="model-download-button"
disabled={disabled || downloading}
title="Download this model now so it is available offline. Otherwise it downloads automatically when an on-device transcription starts."
onClick={onDownloadSelected}
>
{downloading ? "Downloading…" : "Download"}
</button>
)}
</span>
</label>
</li>
);
})}
</ul>
</fieldset>
))}
</section>
);
}