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 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb72a2e646
commit
9f39a82982
38 files changed
+3857
-246
No files matched your search
+153
-16
@@ -1,17 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "./index.css";
|
||||
|
||||
import { AuthForms } from "./components/AuthForms";
|
||||
import { FileDropzone } from "./components/FileDropzone";
|
||||
import { ModelLoadProgress } from "./components/ModelLoadProgress";
|
||||
import { ModelPicker } from "./components/ModelPicker";
|
||||
import { TranscriptView } from "./components/TranscriptView";
|
||||
import { TranscriptionOptions } from "./components/TranscriptionOptions";
|
||||
import { decodeAudioFile } from "./lib/audio";
|
||||
import { DEFAULT_DEVICE, DEFAULT_MODEL_ID } from "./lib/models";
|
||||
import { getDownloadedModels } from "./lib/modelCache";
|
||||
import { registerServiceWorker } from "./pwa/register";
|
||||
import {
|
||||
AUTO_LANGUAGE,
|
||||
DEFAULT_DEVICE_PREFERENCE,
|
||||
DEFAULT_MODEL_ID,
|
||||
DEFAULT_TASK,
|
||||
MODELS,
|
||||
getModel,
|
||||
} from "./lib/models";
|
||||
import {
|
||||
applyLoadProgress,
|
||||
initialLoadProgress,
|
||||
isWorkerToUiMessage,
|
||||
type DevicePreference,
|
||||
type InferenceDevice,
|
||||
type TranscriptChunk,
|
||||
type UiToWorkerMessage,
|
||||
type WhisperTask,
|
||||
} from "./worker/protocol";
|
||||
|
||||
interface TranscriptResult {
|
||||
@@ -19,6 +34,8 @@ interface TranscriptResult {
|
||||
chunks: TranscriptChunk[];
|
||||
}
|
||||
|
||||
const MODEL_IDS = MODELS.map((model) => model.id);
|
||||
|
||||
export function App() {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
@@ -31,8 +48,33 @@ export function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
|
||||
const [devicePreference, setDevicePreference] = useState<DevicePreference>(
|
||||
DEFAULT_DEVICE_PREFERENCE,
|
||||
);
|
||||
const [activeDevice, setActiveDevice] = useState<InferenceDevice | null>(null);
|
||||
const [language, setLanguage] = useState(AUTO_LANGUAGE);
|
||||
const [task, setTask] = useState<WhisperTask>(DEFAULT_TASK);
|
||||
const [downloaded, setDownloaded] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// PWA state (M3, D-007): is a service worker controlling this page (app
|
||||
// shell available offline), and is an updated version waiting?
|
||||
const [swControlled, setSwControlled] = useState(false);
|
||||
const [applyUpdate, setApplyUpdate] = useState<(() => void) | null>(null);
|
||||
|
||||
function refreshDownloaded() {
|
||||
// Downloaded badges (D-005): query Cache Storage for cached weights.
|
||||
getDownloadedModels(MODEL_IDS).then(setDownloaded, () => {});
|
||||
}
|
||||
|
||||
// Terminate the worker when the app unmounts (also keeps HMR tidy).
|
||||
useEffect(() => {
|
||||
refreshDownloaded();
|
||||
// Registration is idempotent, so the StrictMode double-invoke is fine.
|
||||
registerServiceWorker({
|
||||
onControlled: () => setSwControlled(true),
|
||||
onUpdateReady: (apply) => setApplyUpdate(() => apply),
|
||||
});
|
||||
return () => {
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
@@ -52,6 +94,12 @@ export function App() {
|
||||
switch (message.type) {
|
||||
case "load-progress":
|
||||
setLoadProgress((state) => applyLoadProgress(state, message));
|
||||
if (message.status === "ready") {
|
||||
// Surface the backend the worker actually selected (D-004) and
|
||||
// refresh the downloaded badges now that weights are cached.
|
||||
setActiveDevice(message.device);
|
||||
refreshDownloaded();
|
||||
}
|
||||
break;
|
||||
case "partial":
|
||||
setPartialText((text) => text + message.text);
|
||||
@@ -78,6 +126,38 @@ export function App() {
|
||||
worker.postMessage(message, transfer ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a load request and reset per-load UI state so the progress panel
|
||||
* restarts cleanly on model/device switches. Idempotent in the worker for
|
||||
* an unchanged model+device; a change disposes the old pipeline first.
|
||||
*/
|
||||
function startLoad(nextModelId: string, preference: DevicePreference): Worker {
|
||||
const worker = ensureWorker();
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadStarted(true);
|
||||
setError(null);
|
||||
postToWorker(worker, { type: "load", modelId: nextModelId, device: preference });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function handleModelSelect(nextModelId: string) {
|
||||
if (busy || nextModelId === modelId) return;
|
||||
setModelId(nextModelId);
|
||||
setActiveDevice(null);
|
||||
setPartialText("");
|
||||
setResult(null);
|
||||
startLoad(nextModelId, devicePreference);
|
||||
}
|
||||
|
||||
function handleDevicePreference(preference: DevicePreference) {
|
||||
if (busy || 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);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -89,20 +169,28 @@ export function App() {
|
||||
// Kick off the (idempotent) model load immediately so the download runs
|
||||
// while we decode the audio locally.
|
||||
const worker = ensureWorker();
|
||||
postToWorker(worker, {
|
||||
type: "load",
|
||||
modelId: DEFAULT_MODEL_ID,
|
||||
device: DEFAULT_DEVICE,
|
||||
});
|
||||
setLoadStarted(true);
|
||||
if (!loadStarted) {
|
||||
startLoad(modelId, devicePreference);
|
||||
} else {
|
||||
postToWorker(worker, { type: "load", modelId, device: devicePreference });
|
||||
}
|
||||
|
||||
const multilingual = getModel(modelId)?.multilingual ?? false;
|
||||
|
||||
setDecoding(true);
|
||||
try {
|
||||
// Decoded entirely on-device via Web Audio (D-012); the audio never
|
||||
// touches the network (Architecture.md §5).
|
||||
const audio = await decodeAudioFile(file);
|
||||
// Transfer the buffer to the worker — zero copy.
|
||||
postToWorker(worker, { type: "transcribe", audio }, [audio.buffer as ArrayBuffer]);
|
||||
// Transfer the buffer to the worker — zero copy. Language/task only
|
||||
// apply to multilingual models; `.en` variants take neither.
|
||||
postToWorker(
|
||||
worker,
|
||||
multilingual
|
||||
? { type: "transcribe", audio, language, task }
|
||||
: { type: "transcribe", audio },
|
||||
[audio.buffer as ArrayBuffer],
|
||||
);
|
||||
} catch (decodeError) {
|
||||
setError(decodeError instanceof Error ? decodeError.message : String(decodeError));
|
||||
setBusy(false);
|
||||
@@ -111,6 +199,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const model = getModel(modelId);
|
||||
const modelReady = loadProgress.ready;
|
||||
const showModelProgress = loadStarted && !modelReady && !error;
|
||||
const transcribing = busy && !decoding && modelReady;
|
||||
@@ -118,13 +207,40 @@ export function App() {
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<h1>Whisper Web</h1>
|
||||
<p className="tagline">
|
||||
Speech-to-text that runs entirely in your browser. Your audio never
|
||||
leaves this device.
|
||||
</p>
|
||||
<div>
|
||||
<h1>Whisper Web</h1>
|
||||
<p className="tagline">
|
||||
Speech-to-text that runs entirely in your browser. Your audio never
|
||||
leaves this device.
|
||||
</p>
|
||||
</div>
|
||||
{/* Session UI (M4). Anonymous-first: logged out, this is only an
|
||||
unobtrusive "Sign in" button — everything else is unchanged. */}
|
||||
<AuthForms />
|
||||
</header>
|
||||
|
||||
<ModelPicker
|
||||
models={MODELS}
|
||||
selectedId={modelId}
|
||||
onSelect={handleModelSelect}
|
||||
devicePreference={devicePreference}
|
||||
onDevicePreferenceChange={handleDevicePreference}
|
||||
activeDevice={activeDevice}
|
||||
downloaded={downloaded}
|
||||
offlineReady={swControlled}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{model?.multilingual && (
|
||||
<TranscriptionOptions
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
task={task}
|
||||
onTaskChange={setTask}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileDropzone onFile={handleFile} disabled={busy} />
|
||||
|
||||
{fileName && (
|
||||
@@ -141,7 +257,7 @@ export function App() {
|
||||
)}
|
||||
|
||||
{showModelProgress && (
|
||||
<ModelLoadProgress modelId={DEFAULT_MODEL_ID} progress={loadProgress} />
|
||||
<ModelLoadProgress modelId={modelId} progress={loadProgress} />
|
||||
)}
|
||||
|
||||
<TranscriptView
|
||||
@@ -150,9 +266,30 @@ export function App() {
|
||||
transcribing={transcribing}
|
||||
/>
|
||||
|
||||
{applyUpdate && (
|
||||
<div className="update-toast" role="status">
|
||||
<span>A new version of Whisper Web is available.</span>
|
||||
<button type="button" onClick={applyUpdate}>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="update-toast-dismiss"
|
||||
onClick={() => setApplyUpdate(null)}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Model: <code>{DEFAULT_MODEL_ID}</code> · Engine: WASM (on-device)
|
||||
Model: <code>{modelId}</code> · Engine:{" "}
|
||||
{activeDevice === "webgpu"
|
||||
? "WebGPU (on-device)"
|
||||
: activeDevice === "wasm"
|
||||
? "WASM (on-device)"
|
||||
: "on-device — backend selected on first load"}
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { validatePassword, validateUsername } from "../lib/authClient";
|
||||
import { useAuth } from "../lib/useAuth";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
/**
|
||||
* Session-aware header widget (M4): an unobtrusive "Sign in" affordance while
|
||||
* anonymous — the app works fully without an account (Architecture.md §5) —
|
||||
* that opens a compact login/register popover; once signed in, the username
|
||||
* and a "Sign out" button.
|
||||
*
|
||||
* Validation mirrors the server rules for instant feedback, but the server
|
||||
* stays authoritative: its `{ error }` messages are shown verbatim on 4xx.
|
||||
*/
|
||||
export function AuthForms() {
|
||||
const { user, login, register, logout } = useAuth();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
function switchMode(next: Mode) {
|
||||
if (pending || next === mode) return;
|
||||
setMode(next);
|
||||
setError(null); // stale errors from the other form would mislead
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
setOpen((wasOpen) => !wasOpen);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
|
||||
// Client-side pre-flight: full rules for register; login only needs
|
||||
// non-empty fields (the server answers 401 for anything wrong).
|
||||
if (mode === "register") {
|
||||
const problem = validateUsername(username) ?? validatePassword(password);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
} else if (!username || !password) {
|
||||
setError("Enter a username and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "register") {
|
||||
await register(username, password);
|
||||
} else {
|
||||
await login(username, password);
|
||||
}
|
||||
// Success — the hook now holds the user; reset the panel for next time.
|
||||
setOpen(false);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
} catch (submitError) {
|
||||
setError(
|
||||
submitError instanceof Error ? submitError.message : String(submitError),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await logout(); // always resolves; state is cleared even when offline
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="auth">
|
||||
<span className="auth-username" title="Signed in">
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-button"
|
||||
onClick={handleLogout}
|
||||
disabled={pending}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<button
|
||||
type="button"
|
||||
className="auth-button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={togglePanel}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="auth-panel"
|
||||
role="dialog"
|
||||
aria-label="Sign in or create an account"
|
||||
onKeyDown={onPanelKeyDown}
|
||||
>
|
||||
<div className="auth-tabs" role="tablist" aria-label="Account">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "login"}
|
||||
className={`auth-tab${mode === "login" ? " auth-tab-selected" : ""}`}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "register"}
|
||||
className={`auth-tab${mode === "register" ? " auth-tab-selected" : ""}`}
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete={
|
||||
mode === "register" ? "new-password" : "current-password"
|
||||
}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{mode === "register" && (
|
||||
<p className="auth-hint">
|
||||
3–32 characters (letters, digits, _ . -); password ≥ 8
|
||||
characters.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="auth-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="auth-submit" disabled={pending}>
|
||||
{pending
|
||||
? "Working…"
|
||||
: mode === "register"
|
||||
? "Create account"
|
||||
: "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AUTO_LANGUAGE, WHISPER_LANGUAGES } from "../lib/models";
|
||||
import type { WhisperTask } from "../worker/protocol";
|
||||
|
||||
interface TranscriptionOptionsProps {
|
||||
language: string;
|
||||
onLanguageChange: (language: string) => void;
|
||||
task: WhisperTask;
|
||||
onTaskChange: (task: WhisperTask) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language + task controls for multilingual models. The parent renders this
|
||||
* only when the selected model is multilingual — `.en` models take neither.
|
||||
*/
|
||||
export function TranscriptionOptions({
|
||||
language,
|
||||
onLanguageChange,
|
||||
task,
|
||||
onTaskChange,
|
||||
disabled = false,
|
||||
}: TranscriptionOptionsProps) {
|
||||
return (
|
||||
<div className="transcription-options">
|
||||
<label>
|
||||
Language
|
||||
<select
|
||||
value={language}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onLanguageChange(event.target.value)}
|
||||
>
|
||||
<option value={AUTO_LANGUAGE}>Auto-detect</option>
|
||||
{WHISPER_LANGUAGES.map((entry) => (
|
||||
<option key={entry.code} value={entry.code}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Task
|
||||
<select
|
||||
value={task}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onTaskChange(event.target.value as WhisperTask)}
|
||||
>
|
||||
<option value="transcribe">Transcribe</option>
|
||||
<option value="translate">Translate to English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+321
@@ -25,6 +25,14 @@ body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 2.2rem;
|
||||
@@ -36,6 +44,319 @@ body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- AuthForms (M4) --- */
|
||||
|
||||
.auth {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
/* Roughly align the button with the h1 baseline. */
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.auth-username {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.auth-button:hover:enabled {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
width: min(17rem, calc(100vw - 2.5rem));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-tab:hover:enabled {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.auth-tab-selected,
|
||||
.auth-tab-selected:hover:enabled {
|
||||
border-color: var(--accent);
|
||||
background: #232a38;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.auth-panel form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.auth-panel label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-panel input {
|
||||
background: #262b34;
|
||||
color: #e8e8e8;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-panel input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #f2b8b5;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
background: var(--accent);
|
||||
color: #16181d;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --- ModelPicker --- */
|
||||
|
||||
.model-picker {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.model-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-picker-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.model-picker-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.device-select,
|
||||
.transcription-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.model-picker select,
|
||||
.transcription-options select {
|
||||
background: #262b34;
|
||||
color: inherit;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.model-group {
|
||||
border: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.model-group legend {
|
||||
padding: 0;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.model-group ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.model-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.model-group:not(:disabled) .model-option:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.model-option-selected,
|
||||
.model-group:not(:disabled) .model-option-selected:hover {
|
||||
border-color: var(--accent);
|
||||
background: #232a38;
|
||||
}
|
||||
|
||||
.model-group:disabled .model-option {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.model-option input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.model-option-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-option-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.model-option-size {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-badge {
|
||||
border: 1px solid #3d6b4f;
|
||||
background: #1d2b23;
|
||||
color: #9ad6ae;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- PWA / offline (M3) --- */
|
||||
.offline-status {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.offline-status-ready {
|
||||
color: #9ad6ae;
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.update-toast button {
|
||||
background: var(--accent);
|
||||
color: #16181d;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-toast button.update-toast-dismiss {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- TranscriptionOptions --- */
|
||||
|
||||
.transcription-options {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* --- FileDropzone --- */
|
||||
|
||||
.dropzone {
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%EF%B8%8F%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- Relative hrefs: Bun's HTML loader must resolve every <link href> to a
|
||||
real file (absolute URLs fail the build); it hashes + rewrites them.
|
||||
The manifest's own icon URLs stay at the fixed /icons/ paths, which
|
||||
src/index.ts (dev) and scripts/build.ts (production) serve. -->
|
||||
<link rel="manifest" href="./pwa/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#16181d" />
|
||||
<link rel="apple-touch-icon" href="./pwa/icons/icon-192.png" />
|
||||
<title>Whisper Web — on-device speech-to-text</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+186
-35
@@ -1,43 +1,194 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
import { join, normalize, sep } from "node:path";
|
||||
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons";
|
||||
import { injectPrecacheManifest } from "./pwa/precache";
|
||||
import { auth } from "./server/auth";
|
||||
|
||||
// Bun's bundler does not currently follow `new Worker(...)` graphs (verified
|
||||
// with Bun 1.3: both `bun build` and the dev server leave the call untouched),
|
||||
// so the inference worker is bundled here at startup and served from a stable
|
||||
// URL. The UI spawns it with `new Worker("/inference.worker.js")`.
|
||||
const workerBuild = await Bun.build({
|
||||
entrypoints: [
|
||||
Bun.fileURLToPath(new URL("./worker/inference.worker.ts", import.meta.url)),
|
||||
],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: process.env.NODE_ENV === "production",
|
||||
});
|
||||
const workerArtifact = workerBuild.outputs[0];
|
||||
if (!workerArtifact) {
|
||||
throw new Error("Failed to bundle src/worker/inference.worker.ts");
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared static helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOT_FOUND = () => new Response("Not Found", { status: 404 });
|
||||
|
||||
// onnxruntime-web fetches its .wasm binaries at runtime — from the jsdelivr
|
||||
// CDN by default, which would break offline. The worker points
|
||||
// `env.backends.onnx.wasm.wasmPaths` at /ort/ instead (see
|
||||
// src/worker/inference.worker.ts), we serve the files from node_modules
|
||||
// here (production: copied into dist/ort by scripts/build.ts), and the
|
||||
// service worker runtime-caches them cache-first.
|
||||
const ORT_DIST = join(ROOT, "node_modules/onnxruntime-web/dist");
|
||||
export const ORT_FILE_RE = /^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/;
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".wasm": "application/wasm",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
function contentTypeFor(path: string): string | undefined {
|
||||
const dot = path.lastIndexOf(".");
|
||||
return dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)];
|
||||
}
|
||||
const workerScript = await workerArtifact.text();
|
||||
|
||||
// Minimal Bun.serve entry (Architecture.md §2). API routes (auth, server
|
||||
// transcription) arrive in M4/M5 — see src/server/.
|
||||
const server = serve({
|
||||
routes: {
|
||||
"/inference.worker.js": new Response(workerScript, {
|
||||
headers: { "Content-Type": "text/javascript; charset=utf-8" },
|
||||
}),
|
||||
async function serveFile(path: string, cacheControl?: string): Promise<Response> {
|
||||
const file = Bun.file(path);
|
||||
if (!(await file.exists())) return NOT_FOUND();
|
||||
const headers = new Headers();
|
||||
const type = contentTypeFor(path);
|
||||
if (type) headers.set("Content-Type", type);
|
||||
if (cacheControl) headers.set("Cache-Control", cacheControl);
|
||||
return new Response(file, { headers });
|
||||
}
|
||||
|
||||
// Serve the bundled React app for all other routes.
|
||||
"/*": index,
|
||||
},
|
||||
async function serveOrtFile(dir: string, name: string): Promise<Response> {
|
||||
if (!ORT_FILE_RE.test(name)) return NOT_FOUND();
|
||||
return serveFile(join(dir, name));
|
||||
}
|
||||
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
// Enable browser hot reloading in development
|
||||
hmr: true,
|
||||
// ---------------------------------------------------------------------------
|
||||
// API dispatcher — shared by development and production so /api/* behaves
|
||||
// identically in both serve modes (the SW treats it as network-only, D-007).
|
||||
// Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Echo console logs from the browser to the server
|
||||
console: true,
|
||||
},
|
||||
});
|
||||
async function handleApiRequest(request: Request): Promise<Response> {
|
||||
const { pathname } = new URL(request.url);
|
||||
switch (`${request.method} ${pathname}`) {
|
||||
case "POST /api/auth/register":
|
||||
return auth.register(request);
|
||||
case "POST /api/auth/login":
|
||||
return auth.login(request);
|
||||
case "POST /api/auth/logout":
|
||||
return auth.logout(request);
|
||||
case "GET /api/auth/me":
|
||||
return auth.me(request);
|
||||
default:
|
||||
return NOT_FOUND();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Server running at ${server.url}`);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production: serve the prebuilt dist/ directory (created by `bun run build`)
|
||||
// so the URLs match the hashed asset paths precached by dist/sw.js (D-007).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ServeOptions = Parameters<typeof serve>[0];
|
||||
|
||||
async function productionServeOptions(): Promise<ServeOptions> {
|
||||
const dist = join(ROOT, "dist");
|
||||
if (!(await Bun.file(join(dist, "sw.js")).exists())) {
|
||||
throw new Error("dist/ is missing or incomplete — run `bun run build` first.");
|
||||
}
|
||||
return {
|
||||
development: false,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
|
||||
if (url.pathname.startsWith("/api/")) return handleApiRequest(request);
|
||||
|
||||
const relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
|
||||
const path = normalize(join(dist, relative));
|
||||
if (!path.startsWith(dist + sep)) return NOT_FOUND(); // traversal guard
|
||||
|
||||
const response = await serveFile(
|
||||
path,
|
||||
// Hashed chunks are immutable; everything else must revalidate so the
|
||||
// SW update flow sees new versions.
|
||||
/(^|\/)(chunk|index)-[a-z0-9]+\./.test(relative)
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
if (response.status !== 404) return response;
|
||||
// SPA fallback: serve the app shell for unknown navigations.
|
||||
return serveFile(join(dist, "index.html"), "no-cache");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Development: HTML imports + on-the-fly bundles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bun's bundler does not currently follow `new Worker(...)` graphs (verified
|
||||
* with Bun 1.3: both `bun build` and the dev server leave the call untouched),
|
||||
* so worker-like entrypoints are bundled at startup and served from stable
|
||||
* URLs. The UI spawns `new Worker("/inference.worker.js")`; the service
|
||||
* worker registers as "/sw.js".
|
||||
*/
|
||||
async function bundleEntrypoint(relativePath: string): Promise<string> {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [join(import.meta.dir, relativePath)],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
// Never minify: sw.ts relies on its placeholder string literals surviving
|
||||
// bundling verbatim (src/pwa/precache.ts), and dev builds favor debuggability.
|
||||
minify: false,
|
||||
});
|
||||
const artifact = build.outputs[0];
|
||||
if (!artifact) throw new Error(`Failed to bundle src/${relativePath}`);
|
||||
return artifact.text();
|
||||
}
|
||||
|
||||
async function developmentServeOptions(): Promise<ServeOptions> {
|
||||
// src/pwa/icons/*.png are generated, not checked in; index.html links them,
|
||||
// so they must exist before the HTML import bundles it.
|
||||
await ensureIcons();
|
||||
const { default: index } = await import("./index.html");
|
||||
|
||||
const workerScript = await bundleEntrypoint("./worker/inference.worker.ts");
|
||||
// Dev SW: empty precache list — assets are unhashed here, so the shell is
|
||||
// runtime-cached network-first instead (see src/pwa/sw.ts).
|
||||
const swScript = injectPrecacheManifest(
|
||||
await bundleEntrypoint("./pwa/sw.ts"),
|
||||
[],
|
||||
"dev",
|
||||
);
|
||||
const scriptHeaders = { "Content-Type": "text/javascript; charset=utf-8" };
|
||||
|
||||
return {
|
||||
routes: {
|
||||
"/inference.worker.js": new Response(workerScript, { headers: scriptHeaders }),
|
||||
"/sw.js": new Response(swScript, {
|
||||
// The browser's SW update check must always see fresh bytes.
|
||||
headers: { ...scriptHeaders, "Cache-Control": "no-cache" },
|
||||
}),
|
||||
"/manifest.webmanifest": () =>
|
||||
serveFile(join(import.meta.dir, "pwa/manifest.webmanifest")),
|
||||
"/icons/:file": (request) => {
|
||||
const name = request.params.file ?? "";
|
||||
return /^icon-\d+\.png$/.test(name)
|
||||
? serveFile(join(ICONS_DIR, name))
|
||||
: NOT_FOUND();
|
||||
},
|
||||
"/ort/:file": (request) => serveOrtFile(ORT_DIST, request.params.file ?? ""),
|
||||
|
||||
// API routes go through the same dispatcher production uses.
|
||||
"/api/*": handleApiRequest,
|
||||
|
||||
// Serve the bundled React app for all other routes.
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
development: {
|
||||
// Enable browser hot reloading in development
|
||||
hmr: true,
|
||||
|
||||
// Echo console logs from the browser to the server
|
||||
console: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = serve(
|
||||
IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions(),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Server running at ${server.url} (${IS_PRODUCTION ? "production, serving dist/" : "development"})`,
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Auth API client (M4). Deliberately framework-free: validation is pure and
|
||||
* the fetch layer is injectable, so `bun test` can exercise everything without
|
||||
* a DOM or a running server (tests/auth-client.test.ts). React state lives in
|
||||
* `useAuth` (src/lib/useAuth.ts).
|
||||
*
|
||||
* Contract (mirrored by src/server): all endpoints exchange JSON and errors
|
||||
* arrive as `{ error: string }`. The session cookie is HttpOnly, so
|
||||
* `GET /api/auth/me` is the ONLY way to learn the current auth state.
|
||||
*/
|
||||
|
||||
export interface AuthUser {
|
||||
id: number | string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/** Minimal fetch signature so tests can inject a mock. */
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
// Bound lazily via globalThis: bare `fetch` references passed around as values
|
||||
// can throw "Illegal invocation" in some browsers.
|
||||
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
|
||||
|
||||
// --- Client-side validation ---------------------------------------------
|
||||
// Mirrors the server rules for instant feedback; the server stays
|
||||
// authoritative and its `{ error }` messages are surfaced on 4xx regardless.
|
||||
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 32;
|
||||
export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/** Returns a human-readable problem with the username, or null when valid. */
|
||||
export function validateUsername(username: string): string | null {
|
||||
if (
|
||||
username.length < USERNAME_MIN_LENGTH ||
|
||||
username.length > USERNAME_MAX_LENGTH
|
||||
) {
|
||||
return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`;
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(username)) {
|
||||
return "Username may only contain letters, digits, and _ . -";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a human-readable problem with the password, or null when valid. */
|
||||
export function validatePassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- API requests ---------------------------------------------------------
|
||||
|
||||
function isAuthUser(value: unknown): value is AuthUser {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as { id?: unknown; username?: unknown };
|
||||
return (
|
||||
(typeof candidate.id === "number" || typeof candidate.id === "string") &&
|
||||
typeof candidate.username === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */
|
||||
async function readErrorMessage(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
typeof (body as { error?: unknown }).error === "string"
|
||||
) {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON error body (proxy page, empty 500) — use the fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST credentials to an auth endpoint and return the signed-in user.
|
||||
* Throws an `Error` whose message is safe to show in the UI.
|
||||
*/
|
||||
async function requestUser(
|
||||
fetchFn: FetchLike,
|
||||
path: string,
|
||||
credentials: { username: string; password: string },
|
||||
): Promise<AuthUser> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not reach the server. Check your connection.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(response, `Request failed (${response.status}).`),
|
||||
);
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const user = (payload as { user?: unknown } | null)?.user;
|
||||
if (!isAuthUser(user)) {
|
||||
throw new Error("The server returned an unexpected response.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** POST /api/auth/login → user, or throws with the server's error message. */
|
||||
export function login(
|
||||
username: string,
|
||||
password: string,
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser> {
|
||||
return requestUser(fetchFn, "/api/auth/login", { username, password });
|
||||
}
|
||||
|
||||
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */
|
||||
export function register(
|
||||
username: string,
|
||||
password: string,
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser> {
|
||||
return requestUser(fetchFn, "/api/auth/register", { username, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
|
||||
* the app is offline-first and /api/* is network-only in the service worker,
|
||||
* so 401s, network failures, and malformed bodies all resolve to anonymous
|
||||
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
|
||||
*/
|
||||
export async function fetchCurrentUser(
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser | null> {
|
||||
try {
|
||||
const response = await fetchFn("/api/auth/me", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json();
|
||||
const user = (payload as { user?: unknown } | null)?.user;
|
||||
return isAuthUser(user) ? user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout (→ 204). Swallows failures: the caller clears local
|
||||
* state either way, and an unreachable server just means the cookie expires
|
||||
* on its own.
|
||||
*/
|
||||
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
|
||||
try {
|
||||
await fetchFn("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
// Offline — nothing to do.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* "Is this model already downloaded?" helper (Decisions.md D-005).
|
||||
*
|
||||
* Transformers.js v3 persists Hub downloads in the browser Cache API (Cache
|
||||
* Storage, cache name "transformers-cache"). We inspect the cached request
|
||||
* URLs and match them against catalog model ids. Used by `ModelPicker` for the
|
||||
* downloaded badge (M2) and reused by the offline indicator (M3).
|
||||
*
|
||||
* `matchCachedModels` is pure so it can be unit-tested; only
|
||||
* `getDownloadedModels` touches the `caches` global.
|
||||
*/
|
||||
|
||||
/** Cache name used by Transformers.js v3 (`env.cacheName` default). */
|
||||
export const TRANSFORMERS_CACHE_NAME = "transformers-cache";
|
||||
|
||||
/**
|
||||
* Pure matcher: which of `modelIds` have at least one cached ONNX weight file?
|
||||
*
|
||||
* Cached URLs look like
|
||||
* `https://huggingface.co/<modelId>/resolve/main/onnx/<file>.onnx`. We require
|
||||
* an `.onnx` hit (not just tokenizer/config JSON) so a model whose weights
|
||||
* never finished downloading does not show as available.
|
||||
*/
|
||||
export function matchCachedModels(
|
||||
urls: Iterable<string>,
|
||||
modelIds: readonly string[],
|
||||
): Set<string> {
|
||||
const downloaded = new Set<string>();
|
||||
for (const url of urls) {
|
||||
if (!url.split("?")[0]?.endsWith(".onnx")) continue;
|
||||
for (const modelId of modelIds) {
|
||||
if (!downloaded.has(modelId) && url.includes(`/${modelId}/`)) {
|
||||
downloaded.add(modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the browser Cache API for models with cached weights. Returns an
|
||||
* empty set where Cache Storage is unavailable (non-secure contexts, tests).
|
||||
*/
|
||||
export async function getDownloadedModels(
|
||||
modelIds: readonly string[],
|
||||
): Promise<Set<string>> {
|
||||
if (typeof caches === "undefined") return new Set();
|
||||
try {
|
||||
const urls: string[] = [];
|
||||
// Match any transformers-owned cache, in case the name is ever versioned.
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.includes("transformers")) continue;
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) urls.push(request.url);
|
||||
}
|
||||
return matchCachedModels(urls, modelIds);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
+260
-12
@@ -1,20 +1,268 @@
|
||||
/**
|
||||
* Model catalog (Decisions.md D-006).
|
||||
* Curated Whisper model catalog (Decisions.md D-006).
|
||||
*
|
||||
* M1 ships a single hardcoded model. The curated catalog — tiny → large,
|
||||
* English-only vs multilingual, dtype defaults per device, size estimates —
|
||||
* lands in M2.
|
||||
* Pure, typed data — no browser globals — so it is unit-testable with
|
||||
* `bun test` and shareable with the server engine (M5). ONNX conversions come
|
||||
* from the `onnx-community` Hub org (the recommended source for
|
||||
* Transformers.js v3+).
|
||||
*
|
||||
* TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006).
|
||||
* dtype defaults per device follow the community-known-good combinations:
|
||||
* - WASM: q8 for small models, q4 for the large ones (download size).
|
||||
* - WebGPU: fp16 encoder + q4 merged decoder for the big models; fp32 for
|
||||
* tiny/base where fp16 has known accuracy issues.
|
||||
* `verified` is only true for combinations we have actually run in a browser;
|
||||
* the rest are pending manual testing (WorkBreakdown M2).
|
||||
*/
|
||||
|
||||
import type { InferenceDevice } from "../worker/protocol";
|
||||
import type {
|
||||
DevicePreference,
|
||||
DtypeSpec,
|
||||
InferenceDevice,
|
||||
WhisperDtype,
|
||||
WhisperTask,
|
||||
} from "../worker/protocol";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dtype default for one device, with verification status (D-006). */
|
||||
export interface DtypeDefault {
|
||||
dtype: DtypeSpec;
|
||||
/** True only if this exact combination has been run in a real browser. */
|
||||
verified: boolean;
|
||||
/** Why this combination was chosen / caveats. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface WhisperModel {
|
||||
/** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */
|
||||
id: string;
|
||||
/** Human-readable name for the picker. */
|
||||
displayName: string;
|
||||
/** False for `.en` (English-only) variants. */
|
||||
multilingual: boolean;
|
||||
/** Encoder parameter count, in millions (drives size estimates). */
|
||||
encoderParamsM: number;
|
||||
/** Decoder parameter count, in millions (drives size estimates). */
|
||||
decoderParamsM: number;
|
||||
/** Per-device dtype defaults (D-004 / D-006). */
|
||||
defaults: Record<InferenceDevice, DtypeDefault>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size estimation (approximate, shown before download — D-006)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approximate stored bytes per parameter for each quantization level. */
|
||||
export const BYTES_PER_PARAM: Record<WhisperDtype, number> = {
|
||||
fp32: 4,
|
||||
fp16: 2,
|
||||
q8: 1.05, // int8 weights + scales/zero-points overhead
|
||||
q4: 0.6, // 4-bit blocks + per-block scales; some tensors stay higher precision
|
||||
};
|
||||
|
||||
/** Tokenizer + configs + preprocessor, roughly constant per model. */
|
||||
const AUX_FILES_BYTES = 3_000_000;
|
||||
|
||||
/** Approximate total download size for a model at a given dtype spec. */
|
||||
export function estimateDownloadBytes(
|
||||
model: WhisperModel,
|
||||
dtype: DtypeSpec,
|
||||
): number {
|
||||
const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model;
|
||||
const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged;
|
||||
return Math.round(
|
||||
model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] +
|
||||
model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] +
|
||||
AUX_FILES_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
/** "487 MB" / "1.6 GB" style formatting for size estimates. */
|
||||
export function formatModelSize(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
return `${Math.round(bytes / 1e6)} MB`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dtype defaults (community-known-good; see file header)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WASM_Q8: DtypeDefault = {
|
||||
dtype: "q8",
|
||||
verified: false,
|
||||
note: "q8 is the Transformers.js WASM default; good quality/size trade-off.",
|
||||
};
|
||||
|
||||
const WASM_Q4: DtypeDefault = {
|
||||
dtype: "q4",
|
||||
verified: false,
|
||||
note: "q4 keeps the download manageable for larger models on WASM.",
|
||||
};
|
||||
|
||||
/** fp16 has known accuracy issues for the small Whisper encoders on WebGPU. */
|
||||
const WEBGPU_FP32: DtypeDefault = {
|
||||
dtype: { encoder_model: "fp32", decoder_model_merged: "fp32" },
|
||||
verified: false,
|
||||
note: "fp32 both parts: fp16 encoders are known-bad for tiny/base on WebGPU.",
|
||||
};
|
||||
|
||||
const WEBGPU_FP16_Q4: DtypeDefault = {
|
||||
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
|
||||
verified: false,
|
||||
note: "fp16 encoder + q4 merged decoder — the common working WebGPU setup.",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The catalog (D-006: curated, not exhaustive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MODELS: readonly WhisperModel[] = [
|
||||
{
|
||||
id: "onnx-community/whisper-tiny.en",
|
||||
displayName: "Whisper Tiny (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: {
|
||||
// Verified in-browser during M1 (WASM, Transformers.js default q8).
|
||||
wasm: { ...WASM_Q8, verified: true },
|
||||
webgpu: WEBGPU_FP32,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-tiny",
|
||||
displayName: "Whisper Tiny",
|
||||
multilingual: true,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-base.en",
|
||||
displayName: "Whisper Base (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-base",
|
||||
displayName: "Whisper Base",
|
||||
multilingual: true,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-small.en",
|
||||
displayName: "Whisper Small (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-small",
|
||||
displayName: "Whisper Small",
|
||||
multilingual: true,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-medium.en",
|
||||
displayName: "Whisper Medium (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-medium",
|
||||
displayName: "Whisper Medium",
|
||||
multilingual: true,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-large-v3-turbo",
|
||||
displayName: "Whisper Large v3 Turbo",
|
||||
multilingual: true,
|
||||
encoderParamsM: 635,
|
||||
decoderParamsM: 174,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
];
|
||||
|
||||
export function getModel(id: string): WhisperModel | undefined {
|
||||
return MODELS.find((model) => model.id === id);
|
||||
}
|
||||
|
||||
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
|
||||
export function resolveDefaultDtype(
|
||||
modelId: string,
|
||||
device: InferenceDevice,
|
||||
): DtypeSpec {
|
||||
return getModel(modelId)?.defaults[device].dtype ?? "q8";
|
||||
}
|
||||
|
||||
/** Approximate download size using the model's default dtype for `device`. */
|
||||
export function estimateDefaultDownloadBytes(
|
||||
model: WhisperModel,
|
||||
device: InferenceDevice,
|
||||
): number {
|
||||
return estimateDownloadBytes(model, model.defaults[device].dtype);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults & language list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one model M1 supports. `onnx-community` is the recommended org for
|
||||
* Transformers.js v3+ ONNX conversions.
|
||||
*/
|
||||
export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en";
|
||||
|
||||
/** M1 runs WASM only; WebGPU detection arrives in M2 (D-004). */
|
||||
export const DEFAULT_DEVICE: InferenceDevice = "wasm";
|
||||
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
|
||||
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
|
||||
|
||||
export const DEFAULT_TASK: WhisperTask = "transcribe";
|
||||
|
||||
/** Sentinel for "let Whisper detect the language". */
|
||||
export const AUTO_LANGUAGE = "auto";
|
||||
|
||||
/**
|
||||
* Curated language list for multilingual models (Whisper language codes).
|
||||
* Not exhaustive — Whisper supports ~100; extend on demand.
|
||||
*/
|
||||
export const WHISPER_LANGUAGES: readonly { code: string; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "da", label: "Danish" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "it", label: "Italian" },
|
||||
{ code: "nl", label: "Dutch" },
|
||||
{ code: "no", label: "Norwegian" },
|
||||
{ code: "sv", label: "Swedish" },
|
||||
{ code: "fi", label: "Finnish" },
|
||||
{ code: "is", label: "Icelandic" },
|
||||
{ code: "pl", label: "Polish" },
|
||||
{ code: "pt", label: "Portuguese" },
|
||||
{ code: "ro", label: "Romanian" },
|
||||
{ code: "cs", label: "Czech" },
|
||||
{ code: "el", label: "Greek" },
|
||||
{ code: "hu", label: "Hungarian" },
|
||||
{ code: "uk", label: "Ukrainian" },
|
||||
{ code: "ru", label: "Russian" },
|
||||
{ code: "tr", label: "Turkish" },
|
||||
{ code: "ar", label: "Arabic" },
|
||||
{ code: "he", label: "Hebrew" },
|
||||
{ code: "hi", label: "Hindi" },
|
||||
{ code: "id", label: "Indonesian" },
|
||||
{ code: "vi", label: "Vietnamese" },
|
||||
{ code: "th", label: "Thai" },
|
||||
{ code: "zh", label: "Chinese" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
register as registerRequest,
|
||||
type AuthUser,
|
||||
} from "./authClient";
|
||||
|
||||
export interface AuthState {
|
||||
/** The signed-in user, or null while anonymous (the default). */
|
||||
user: AuthUser | null;
|
||||
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** Rejects with a UI-safe Error message on failure (400/409). */
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
/** Always resolves; local state is cleared even if the server is down. */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
|
||||
* only source of truth is GET /api/auth/me, probed once on mount.
|
||||
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
|
||||
* down) both resolve to anonymous, keeping the anonymous-first invariant:
|
||||
* a failing /me must not surface any error.
|
||||
*/
|
||||
export function useAuth(): AuthState {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchCurrentUser().then((current) => {
|
||||
// Guard against StrictMode double-mounts / fast unmounts.
|
||||
if (!cancelled && current) setUser(current);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setUser(await loginRequest(username, password));
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
// Register signs the user in immediately (the 201 sets the cookie).
|
||||
setUser(await registerRequest(username, password));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return { user, login, register, logout };
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,7 +1,25 @@
|
||||
{
|
||||
"$comment": "TODO(M3): finalize PWA manifest — icons, theme colors, installability (D-007).",
|
||||
"name": "Whisper Web",
|
||||
"short_name": "Whisper",
|
||||
"name": "Whisper Web — on-device speech-to-text",
|
||||
"short_name": "Whisper Web",
|
||||
"description": "Speech-to-text that runs entirely in your browser via OpenAI Whisper. Works offline once installed with a downloaded model.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"display": "standalone"
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#16181d",
|
||||
"background_color": "#16181d",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Precache-manifest helpers (Decisions.md D-007).
|
||||
*
|
||||
* `src/pwa/sw.ts` ships with two string placeholders; these helpers turn a
|
||||
* `bun build` output listing into URL paths and splice them into the bundled
|
||||
* service-worker source. Pure functions so they are unit-testable
|
||||
* (tests/precache.test.ts); used by `scripts/build.ts` (production, real
|
||||
* asset list) and `src/index.ts` (dev, empty list + "dev" version).
|
||||
*/
|
||||
|
||||
/** Literal that `sw.ts` JSON.parses; replaced with the asset-list JSON. */
|
||||
export const PRECACHE_PLACEHOLDER = '"__PRECACHE_MANIFEST__"';
|
||||
/** Literal inside the SW's cache-name string; replaced with a build version. */
|
||||
export const VERSION_PLACEHOLDER = "__CACHE_VERSION__";
|
||||
|
||||
/**
|
||||
* Map bundler output paths (relative to the output dir, e.g. `index.html`,
|
||||
* `chunk-abc123.js`) plus extra fixed routes to the URL paths the service
|
||||
* worker should precache. Sourcemaps are excluded; `index.html` becomes `/`
|
||||
* (the URL the app shell is actually served and navigated to).
|
||||
*/
|
||||
export function precachePathsFromOutputs(
|
||||
outputPaths: Iterable<string>,
|
||||
extraPaths: readonly string[] = [],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const outputPath of outputPaths) {
|
||||
const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, "");
|
||||
if (normalized.endsWith(".map")) continue;
|
||||
paths.add(normalized === "index.html" ? "/" : `/${normalized}`);
|
||||
}
|
||||
for (const extra of extraPaths) paths.add(extra);
|
||||
return [...paths].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the placeholders in bundled service-worker source with the actual
|
||||
* precache list and cache version. Throws if a placeholder is missing (guards
|
||||
* against the bundler mangling the literals) or if the version would produce
|
||||
* an unusable cache name.
|
||||
*/
|
||||
export function injectPrecacheManifest(
|
||||
swSource: string,
|
||||
assetPaths: readonly string[],
|
||||
version: string,
|
||||
): string {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(version)) {
|
||||
throw new Error(`Invalid cache version "${version}"`);
|
||||
}
|
||||
if (!swSource.includes(PRECACHE_PLACEHOLDER)) {
|
||||
throw new Error(`Service worker source is missing ${PRECACHE_PLACEHOLDER}`);
|
||||
}
|
||||
if (!swSource.includes(VERSION_PLACEHOLDER)) {
|
||||
throw new Error(`Service worker source is missing ${VERSION_PLACEHOLDER}`);
|
||||
}
|
||||
return swSource
|
||||
// Double-stringify: the placeholder is a *string literal* in the SW that
|
||||
// gets JSON.parsed at runtime, so the replacement must be a JS string
|
||||
// literal whose contents are the JSON array.
|
||||
.replace(PRECACHE_PLACEHOLDER, JSON.stringify(JSON.stringify(assetPaths)))
|
||||
.replace(VERSION_PLACEHOLDER, version);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Service-worker registration + update flow (Decisions.md D-007).
|
||||
*
|
||||
* Registered in both dev and production — the dev SW is network-first so HMR
|
||||
* is unaffected. When an updated SW finishes installing while an old one is
|
||||
* controlling the page, `onUpdateReady` fires with an `applyUpdate` callback;
|
||||
* the UI shows a toast, and invoking the callback tells the waiting worker to
|
||||
* skipWaiting and reloads once it takes control. The first-ever install
|
||||
* (clients.claim with no prior controller) never triggers a reload.
|
||||
*/
|
||||
|
||||
export interface RegisterOptions {
|
||||
/** A new version is installed and waiting; call `applyUpdate` to activate + reload. */
|
||||
onUpdateReady: (applyUpdate: () => void) => void;
|
||||
/** The page is (or becomes) controlled by an active service worker. */
|
||||
onControlled?: () => void;
|
||||
}
|
||||
|
||||
export function registerServiceWorker(options: RegisterOptions): void {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const container = navigator.serviceWorker;
|
||||
|
||||
let userInitiatedUpdate = false;
|
||||
container.addEventListener("controllerchange", () => {
|
||||
// Only reload when the user accepted the update toast — the claim() on
|
||||
// first install also fires this event and must not reload the page.
|
||||
if (userInitiatedUpdate) location.reload();
|
||||
else options.onControlled?.();
|
||||
});
|
||||
|
||||
if (container.controller) options.onControlled?.();
|
||||
|
||||
container
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
const notify = (worker: ServiceWorker) => {
|
||||
options.onUpdateReady(() => {
|
||||
userInitiatedUpdate = true;
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
});
|
||||
};
|
||||
// A version may already be waiting from a previous visit.
|
||||
if (registration.waiting && container.controller) notify(registration.waiting);
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const installing = registration.installing;
|
||||
if (!installing) return;
|
||||
installing.addEventListener("statechange", () => {
|
||||
if (installing.state === "installed" && container.controller) {
|
||||
notify(installing);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Service worker registration failed:", error);
|
||||
});
|
||||
}
|
||||
+131
-5
@@ -1,10 +1,136 @@
|
||||
/**
|
||||
* Service worker (Decisions.md D-007) — TODO(M3).
|
||||
* Service worker (Decisions.md D-007) — hand-rolled, no Workbox.
|
||||
*
|
||||
* Plan: precache the built app shell (hashed assets emitted by `bun build`
|
||||
* into a generated manifest), cache-first for static assets, network-only for
|
||||
* `/api/*`. Model weights are NOT cached here — Transformers.js keeps them in
|
||||
* the browser Cache API itself (D-005).
|
||||
* Strategy:
|
||||
* - App shell: precached on install (production; list injected by
|
||||
* `scripts/build.ts` via the placeholders below), cache-first thereafter.
|
||||
* In dev the list is empty and the shell is served network-first with a
|
||||
* cache fallback, so HMR keeps working while offline still functions.
|
||||
* - `/ort/*` (onnxruntime .wasm/.mjs, served from our origin — see
|
||||
* src/index.ts): runtime-cached, cache-first. These are required for
|
||||
* offline inference but too large to precache eagerly.
|
||||
* - `/api/*`: network-only — never cached (auth/server engine, M4/M5).
|
||||
* - Model weights are NOT cached here (D-005): Transformers.js persists Hub
|
||||
* downloads in its own `transformers-cache`, which we must never touch.
|
||||
*/
|
||||
|
||||
// Placeholders replaced by injectPrecacheManifest (src/pwa/precache.ts).
|
||||
// In dev src/index.ts injects an empty list + "dev"; in production
|
||||
// scripts/build.ts injects the hashed dist asset list + a content hash.
|
||||
const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[];
|
||||
const VERSION = "__CACHE_VERSION__";
|
||||
|
||||
const SHELL_CACHE = `whisper-shell-${VERSION}`;
|
||||
const RUNTIME_CACHE = `whisper-runtime-${VERSION}`;
|
||||
/** Caches owned by this SW — anything else (e.g. transformers-cache) is kept. */
|
||||
const OWN_CACHE_PREFIXES = ["whisper-shell-", "whisper-runtime-"];
|
||||
|
||||
// `lib: ["DOM"]` types `self` as Window; declare the worker-scope shape we
|
||||
// use (same approach as src/worker/inference.worker.ts).
|
||||
interface ExtendableEventLike extends Event {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
}
|
||||
interface FetchEventLike extends ExtendableEventLike {
|
||||
readonly request: Request;
|
||||
respondWith(response: Promise<Response> | Response): void;
|
||||
}
|
||||
interface ServiceWorkerScope {
|
||||
location: Location;
|
||||
skipWaiting(): Promise<void>;
|
||||
clients: { claim(): Promise<void> };
|
||||
addEventListener(type: "install" | "activate", listener: (event: ExtendableEventLike) => void): void;
|
||||
addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void;
|
||||
addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
|
||||
}
|
||||
const sw = self as unknown as ServiceWorkerScope;
|
||||
|
||||
sw.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
if (PRECACHE.length === 0) return; // dev — runtime caching only
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await cache.addAll(PRECACHE);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Drop caches from previous SW versions; never touch transformers-cache.
|
||||
const stale = (await caches.keys()).filter(
|
||||
(name) =>
|
||||
OWN_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)) &&
|
||||
name !== SHELL_CACHE &&
|
||||
name !== RUNTIME_CACHE,
|
||||
);
|
||||
await Promise.all(stale.map((name) => caches.delete(name)));
|
||||
// Control already-open pages so /ort/* caching works without a reload.
|
||||
await sw.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// The update toast posts this after user consent (src/pwa/register.ts).
|
||||
sw.addEventListener("message", (event) => {
|
||||
const data = event.data as { type?: string } | null;
|
||||
if (data?.type === "SKIP_WAITING") void sw.skipWaiting();
|
||||
});
|
||||
|
||||
/** Serve from `cacheName`, populating it from the network on first miss. */
|
||||
async function cacheFirst(cacheName: string, request: Request): Promise<Response> {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
const response = await fetch(request);
|
||||
if (response.status === 200) await cache.put(request, response.clone());
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, mirroring successes into the shell cache; on network failure fall
|
||||
* back to the cache (and `fallbackPath`, e.g. "/" for navigations).
|
||||
*/
|
||||
async function networkFirst(request: Request, fallbackPath: string | null): Promise<Response> {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.status === 200) await cache.put(request, response.clone());
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
if (fallbackPath) {
|
||||
const fallback = await cache.match(fallbackPath);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
sw.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
// Cross-origin (Hugging Face Hub model weights, etc.): hands off —
|
||||
// Transformers.js caches those itself in transformers-cache (D-005).
|
||||
if (url.origin !== sw.location.origin) return;
|
||||
// API is network-only (D-007) — auth and the server engine must never be
|
||||
// served stale from cache.
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (url.pathname.startsWith("/ort/")) {
|
||||
event.respondWith(cacheFirst(RUNTIME_CACHE, request));
|
||||
return;
|
||||
}
|
||||
if (PRECACHE.includes(url.pathname)) {
|
||||
event.respondWith(cacheFirst(SHELL_CACHE, request));
|
||||
return;
|
||||
}
|
||||
// Anything else (dev's unhashed shell, navigations to deep links):
|
||||
// network-first with cache fallback; navigations fall back to the shell.
|
||||
event.respondWith(networkFirst(request, request.mode === "navigate" ? "/" : null));
|
||||
});
|
||||
|
||||
export {};
|
||||
+287
-7
@@ -1,11 +1,291 @@
|
||||
/**
|
||||
* Auth — TODO(M4).
|
||||
* Auth: Passport.js local strategy behind a connect-compat adapter over
|
||||
* Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite
|
||||
* (D-009).
|
||||
*
|
||||
* Passport.js local strategy behind a connect-compat adapter over Bun.serve
|
||||
* (Decisions.md D-008): wrap Bun's Request in a minimal req/res shim, run the
|
||||
* passport authenticate chain, translate back to a Response. Sessions in
|
||||
* bun:sqlite with HttpOnly cookies (D-009). Routes:
|
||||
* POST /api/auth/register|login|logout, GET /api/auth/me.
|
||||
* Passport expects connect/express-style middleware, which Bun.serve does not
|
||||
* have. The adapter here is deliberately minimal and honest: it shims only
|
||||
* what `passport.authenticate("local", callback)` actually touches — the
|
||||
* strategy reads `req.body`/`req.query` and, because we pass a custom
|
||||
* callback with `session: false`, never writes to `res`. Session issuance,
|
||||
* cookies, and serialization are driven by us around that call rather than by
|
||||
* passport's own connect middleware.
|
||||
*
|
||||
* Routes implemented (wired in src/index.ts, shared by dev + production):
|
||||
* POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409
|
||||
* POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401
|
||||
* POST /api/auth/logout → 204 (always) + cookie cleared
|
||||
* GET /api/auth/me → 200 {user} | 401
|
||||
*
|
||||
* Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days;
|
||||
* Secure when NODE_ENV=production. Expiry is enforced server-side via
|
||||
* sessions.expires_at — a replayed cookie past that instant is rejected even
|
||||
* though the browser would still send it.
|
||||
*/
|
||||
|
||||
export {};
|
||||
import passport from "passport";
|
||||
import { Strategy as LocalStrategy } from "passport-local";
|
||||
import { type AppDb, type User, getDb } from "./db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The safe-to-serialize projection of a user (never the password hash). */
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthHandlers {
|
||||
register(request: Request): Promise<Response>;
|
||||
login(request: Request): Promise<Response>;
|
||||
logout(request: Request): Promise<Response>;
|
||||
me(request: Request): Promise<Response>;
|
||||
/**
|
||||
* Resolves the user behind a request's `sid` cookie, or null. Exported so
|
||||
* the M5 transcribe endpoint can guard itself with the same session logic.
|
||||
*/
|
||||
getSessionUser(request: Request): SessionUser | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants + small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COOKIE_NAME = "sid";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
function json(body: unknown, status: number, headers?: HeadersInit): Response {
|
||||
const h = new Headers(headers);
|
||||
h.set("Content-Type", "application/json");
|
||||
return new Response(JSON.stringify(body), { status, headers: h });
|
||||
}
|
||||
|
||||
/** Serializes the session cookie per the contract in the module doc comment. */
|
||||
function sessionCookie(sid: string, maxAgeSeconds: number): string {
|
||||
const attributes = [
|
||||
`${COOKIE_NAME}=${sid}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Path=/",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
];
|
||||
// Checked per-call (not at module load) so tests can flip NODE_ENV.
|
||||
if (process.env.NODE_ENV === "production") attributes.push("Secure");
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
/** Max-Age=0 tells the browser to drop the cookie immediately. */
|
||||
const clearedCookie = (): string => sessionCookie("", 0);
|
||||
|
||||
/** Extracts the `sid` cookie value from a request, if any. */
|
||||
function readSessionId(request: Request): string | null {
|
||||
const header = request.headers.get("cookie");
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === COOKIE_NAME) {
|
||||
const value = part.slice(eq + 1).trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const toSessionUser = (user: User): SessionUser => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON `{username, password}` body. Returns null for anything that
|
||||
* is not that shape (bad JSON, wrong types) — callers answer 400.
|
||||
*/
|
||||
async function parseCredentials(request: Request): Promise<Credentials | null> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof body !== "object" || body === null) return null;
|
||||
const { username, password } = body as Record<string, unknown>;
|
||||
if (typeof username !== "string" || typeof password !== "string") return null;
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* Argon2id hash used to burn comparable time when the username does not
|
||||
* exist, so login latency doesn't reveal which usernames are registered.
|
||||
* Computed lazily (hashing is deliberately slow) and cached.
|
||||
*/
|
||||
let dummyHash: Promise<string> | undefined;
|
||||
const getDummyHash = (): Promise<string> =>
|
||||
(dummyHash ??= Bun.password.hash("dummy-password-for-constant-time"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the four route handlers plus `getSessionUser` against a database
|
||||
* source. Taking a getter (not an instance) keeps the default export lazy:
|
||||
* importing this module never opens ./data/app.db, and tests inject
|
||||
* `() => createDb(":memory:")` databases without touching disk.
|
||||
*/
|
||||
export function createAuth(getDatabase: () => AppDb): AuthHandlers {
|
||||
// A private Authenticator instance (not the module-level singleton) so two
|
||||
// createAuth() calls — e.g. app + tests — can't clobber each other's
|
||||
// strategy registration.
|
||||
const authenticator = new passport.Passport();
|
||||
|
||||
authenticator.use(
|
||||
new LocalStrategy((username, password, done) => {
|
||||
// passport-local's verify is callback-style; bridge to async/await and
|
||||
// route thrown errors into done() so they surface as 500s, not hangs.
|
||||
void (async () => {
|
||||
const user = getDatabase().findUserByUsername(username);
|
||||
if (!user) {
|
||||
await Bun.password.verify(password, await getDummyHash());
|
||||
return done(null, false, { message: "invalid credentials" });
|
||||
}
|
||||
const ok = await Bun.password.verify(password, user.passwordHash);
|
||||
return ok
|
||||
? done(null, user)
|
||||
: done(null, false, { message: "invalid credentials" });
|
||||
})().catch(done);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The connect-compat adapter (D-008): run passport's authenticate chain
|
||||
* against a shim of exactly the surface passport-local reads. With
|
||||
* `session: false` and a custom callback, passport calls back with
|
||||
* (err, user|false) and never touches `res`, so an empty object suffices.
|
||||
*/
|
||||
function authenticateLocal(credentials: Credentials): Promise<User | false> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = {
|
||||
body: credentials,
|
||||
query: {},
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
};
|
||||
const middleware = authenticator.authenticate(
|
||||
"local",
|
||||
{ session: false },
|
||||
(err: unknown, user: unknown) =>
|
||||
err ? reject(err) : resolve((user as User | false) || false),
|
||||
) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
|
||||
// next() only fires on internal errors here (the callback intercepts
|
||||
// success and failure), so treat any call as a failure path.
|
||||
middleware(req, {}, (err?: unknown) =>
|
||||
err ? reject(err) : resolve(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Issues a fresh session row + Set-Cookie header for `user`. */
|
||||
function issueSession(user: User): { user: SessionUser; cookie: string } {
|
||||
const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS);
|
||||
return {
|
||||
user: toSessionUser(user),
|
||||
cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionUser(request: Request): SessionUser | null {
|
||||
const sid = readSessionId(request);
|
||||
if (!sid) return null;
|
||||
const db = getDatabase();
|
||||
const session = db.findValidSession(sid);
|
||||
if (!session) return null;
|
||||
const user = db.findUserById(session.userId);
|
||||
return user ? toSessionUser(user) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
async register(request) {
|
||||
const credentials = await parseCredentials(request);
|
||||
if (!credentials) {
|
||||
return json({ error: "expected JSON body with username and password" }, 400);
|
||||
}
|
||||
if (!USERNAME_RE.test(credentials.username)) {
|
||||
return json(
|
||||
{ error: "username must be 3-32 characters (letters, digits, _ . -)" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (credentials.password.length < MIN_PASSWORD_LENGTH) {
|
||||
return json(
|
||||
{ error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
// Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4.
|
||||
const passwordHash = await Bun.password.hash(credentials.password);
|
||||
const user = getDatabase().createUser(credentials.username, passwordHash);
|
||||
// createUser lets the UNIQUE constraint arbitrate, so concurrent
|
||||
// registrations of the same name can't both succeed.
|
||||
if (!user) return json({ error: "username taken" }, 409);
|
||||
|
||||
const issued = issueSession(user);
|
||||
return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie });
|
||||
},
|
||||
|
||||
async login(request) {
|
||||
const credentials = await parseCredentials(request);
|
||||
if (!credentials) {
|
||||
return json({ error: "expected JSON body with username and password" }, 400);
|
||||
}
|
||||
const user = await authenticateLocal(credentials);
|
||||
// One message for both unknown-user and wrong-password: no username
|
||||
// enumeration via response text (timing handled by the dummy hash).
|
||||
if (!user) return json({ error: "invalid credentials" }, 401);
|
||||
|
||||
const issued = issueSession(user);
|
||||
return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie });
|
||||
},
|
||||
|
||||
async logout(request) {
|
||||
const sid = readSessionId(request);
|
||||
if (sid) getDatabase().deleteSession(sid);
|
||||
// Idempotent by contract: 204 whether or not a session existed. No body
|
||||
// on 204, but always clear the cookie so stale clients converge.
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { "Set-Cookie": clearedCookie() },
|
||||
});
|
||||
},
|
||||
|
||||
async me(request) {
|
||||
const user = getSessionUser(request);
|
||||
return user
|
||||
? json({ user }, 200)
|
||||
: json({ error: "not authenticated" }, 401);
|
||||
},
|
||||
|
||||
getSessionUser,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default instance (the real app database) + convenience re-export for M5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auth: AuthHandlers = createAuth(getDb);
|
||||
|
||||
/** Session lookup against the app database — see AuthHandlers.getSessionUser. */
|
||||
export function getSessionUser(request: Request): SessionUser | null {
|
||||
return auth.getSessionUser(request);
|
||||
}
|
||||
+202
-5
@@ -1,9 +1,206 @@
|
||||
/**
|
||||
* Database — TODO(M4).
|
||||
* Users + sessions in bun:sqlite (Decisions.md D-009).
|
||||
*
|
||||
* bun:sqlite (Decisions.md D-009). Tables: users (id, username, password_hash
|
||||
* via Bun.password Argon2), sessions (id, user_id, expires_at). DB path from
|
||||
* env, default ./data/app.db.
|
||||
* Single-file database; path from env `DB_PATH`, default ./data/app.db. All
|
||||
* access goes through the typed helpers on {@link AppDb} — no raw SQL leaks
|
||||
* out of this module, so the (deliberately tiny) schema can evolve in one
|
||||
* place.
|
||||
*
|
||||
* Testability: `createDb(path)` is a pure factory — tests pass ":memory:" and
|
||||
* never touch the on-disk default. The shared instance is created lazily by
|
||||
* `getDb()`, so merely importing this module has no filesystem side effects.
|
||||
*/
|
||||
|
||||
export {};
|
||||
import { Database } from "bun:sqlite";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types (camelCase; the snake_case row shapes stay private below)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
/** Argon2id hash produced by `Bun.password.hash` — never the plaintext. */
|
||||
passwordHash: string;
|
||||
/** Milliseconds since epoch. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
/** 256-bit random id (base64url); doubles as the `sid` cookie value. */
|
||||
id: string;
|
||||
userId: number;
|
||||
/** Milliseconds since epoch; sessions past this instant are invalid. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AppDb {
|
||||
/** Returns the new user, or null when the username is already taken. */
|
||||
createUser(username: string, passwordHash: string): User | null;
|
||||
findUserByUsername(username: string): User | null;
|
||||
findUserById(id: number): User | null;
|
||||
/** Creates a session for `userId`; also sweeps expired rows opportunistically. */
|
||||
createSession(userId: number, expiresAt: number): Session;
|
||||
/**
|
||||
* Returns the session only when it exists AND has not expired — expiry is
|
||||
* enforced here, server-side, regardless of what the cookie claims.
|
||||
*/
|
||||
findValidSession(id: string, now?: number): Session | null;
|
||||
deleteSession(id: string): void;
|
||||
/** Removes all sessions with expires_at <= now; returns the count removed. */
|
||||
deleteExpiredSessions(now?: number): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
const toUser = (row: UserRow): User => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const toSession = (row: SessionRow): Session => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
expiresAt: row.expires_at,
|
||||
});
|
||||
|
||||
/**
|
||||
* Session ids come from the CSPRNG: 32 random bytes = 256 bits, well past the
|
||||
* ~128-bit unguessability floor, encoded base64url so the value is safe in a
|
||||
* cookie without escaping.
|
||||
*/
|
||||
export function newSessionId(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
/** Opens (creating if needed) a database at `path` and applies the schema. */
|
||||
export function createDb(path: string): AppDb {
|
||||
if (path !== ":memory:") {
|
||||
// bun:sqlite creates the file but not its parent directory.
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(path, { create: true, strict: true });
|
||||
// WAL lets reads proceed during writes; harmless no-op for ":memory:".
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
// Enforce sessions.user_id → users.id (off by default in SQLite).
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
`);
|
||||
|
||||
const insertUser = db.query<UserRow, [string, string, number]>(
|
||||
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?) RETURNING *",
|
||||
);
|
||||
const userByUsername = db.query<UserRow, [string]>(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
);
|
||||
const userById = db.query<UserRow, [number]>("SELECT * FROM users WHERE id = ?");
|
||||
const insertSession = db.query<SessionRow, [string, number, number]>(
|
||||
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?) RETURNING *",
|
||||
);
|
||||
const sessionById = db.query<SessionRow, [string, number]>(
|
||||
"SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
|
||||
);
|
||||
const removeSession = db.query("DELETE FROM sessions WHERE id = ?");
|
||||
const removeExpired = db.query("DELETE FROM sessions WHERE expires_at <= ?");
|
||||
|
||||
return {
|
||||
createUser(username, passwordHash) {
|
||||
try {
|
||||
return toUser(insertUser.get(username, passwordHash, Date.now())!);
|
||||
} catch (error) {
|
||||
// The UNIQUE constraint is the source of truth for "username taken" —
|
||||
// checking first would race concurrent registrations.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error as { code?: string }).code?.startsWith("SQLITE_CONSTRAINT")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
findUserByUsername(username) {
|
||||
const row = userByUsername.get(username);
|
||||
return row ? toUser(row) : null;
|
||||
},
|
||||
|
||||
findUserById(id) {
|
||||
const row = userById.get(id);
|
||||
return row ? toUser(row) : null;
|
||||
},
|
||||
|
||||
createSession(userId, expiresAt) {
|
||||
// Piggyback garbage collection on the write path so stale rows never
|
||||
// accumulate unboundedly without needing a background timer.
|
||||
removeExpired.run(Date.now());
|
||||
return toSession(insertSession.get(newSessionId(), userId, expiresAt)!);
|
||||
},
|
||||
|
||||
findValidSession(id, now = Date.now()) {
|
||||
const row = sessionById.get(id, now);
|
||||
return row ? toSession(row) : null;
|
||||
},
|
||||
|
||||
deleteSession(id) {
|
||||
removeSession.run(id);
|
||||
},
|
||||
|
||||
deleteExpiredSessions(now = Date.now()) {
|
||||
return removeExpired.run(now).changes;
|
||||
},
|
||||
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazily-created default instance (the real app database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_DB_PATH = "./data/app.db";
|
||||
|
||||
let defaultDb: AppDb | undefined;
|
||||
|
||||
/** The application database (env `DB_PATH`, default ./data/app.db — gitignored). */
|
||||
export function getDb(): AppDb {
|
||||
defaultDb ??= createDb(process.env.DB_PATH ?? DEFAULT_DB_PATH);
|
||||
return defaultDb;
|
||||
}
|
||||
+135
-23
@@ -2,24 +2,38 @@
|
||||
* Inference Web Worker (Decisions.md D-003 — inference never runs on the main
|
||||
* thread). Hosts the Transformers.js `automatic-speech-recognition` pipeline.
|
||||
*
|
||||
* M1: hardcoded-model, WASM-only. WebGPU detection + fallback lands in M2
|
||||
* (D-004); model switching/disposal also lands in M2.
|
||||
* M2: WebGPU detection with automatic WASM fallback (D-004), catalog-driven
|
||||
* dtype defaults (D-006), model switching with pipeline disposal, and
|
||||
* language/task options for multilingual models.
|
||||
*/
|
||||
|
||||
import {
|
||||
env,
|
||||
pipeline,
|
||||
WhisperTextStreamer,
|
||||
type AutomaticSpeechRecognitionPipeline,
|
||||
type ProgressInfo,
|
||||
} from "@huggingface/transformers";
|
||||
import { resolveDefaultDtype } from "../lib/models";
|
||||
import {
|
||||
isUiToWorkerMessage,
|
||||
type DevicePreference,
|
||||
type InferenceDevice,
|
||||
type LoadMessage,
|
||||
type ResultMessage,
|
||||
type TranscribeMessage,
|
||||
type TranscriptChunk,
|
||||
type WorkerToUiMessage,
|
||||
} from "./protocol";
|
||||
|
||||
// Load onnxruntime's .wasm binaries from our own origin instead of the
|
||||
// jsdelivr CDN default — required for offline use (M3). The server exposes
|
||||
// them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts)
|
||||
// and the service worker runtime-caches them cache-first (src/pwa/sw.ts).
|
||||
if (env.backends.onnx.wasm) {
|
||||
env.backends.onnx.wasm.wasmPaths = "/ort/";
|
||||
}
|
||||
|
||||
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
|
||||
interface DedicatedWorkerScope {
|
||||
postMessage(message: WorkerToUiMessage): void;
|
||||
@@ -38,7 +52,12 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Forward Transformers.js progress events to the UI as load-progress messages. */
|
||||
/**
|
||||
* Forward Transformers.js progress events to the UI as load-progress messages.
|
||||
* The library's own "ready" event is swallowed — the worker posts its own
|
||||
* `ready` (with the selected device) once the pipeline actually resolved,
|
||||
* which also covers the WebGPU → WASM fallback retry (D-004).
|
||||
*/
|
||||
function forwardProgress(info: ProgressInfo): void {
|
||||
switch (info.status) {
|
||||
case "initiate":
|
||||
@@ -56,29 +75,113 @@ function forwardProgress(info: ProgressInfo): void {
|
||||
progress: info.progress,
|
||||
});
|
||||
break;
|
||||
case "ready":
|
||||
post({ type: "load-progress", status: "ready" });
|
||||
break;
|
||||
default:
|
||||
// e.g. "progress_total" — the UI derives totals per-file itself.
|
||||
// "ready" (posted by the worker itself, with the device) and
|
||||
// "progress_total" — the UI derives totals per-file itself.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let asrPromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device selection (D-004): WebGPU when available, automatic WASM fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||
// Idempotent for M1's single hardcoded model: repeated "load" messages
|
||||
// reuse the in-flight/completed pipeline.
|
||||
asrPromise ??= pipeline("automatic-speech-recognition", message.modelId, {
|
||||
device: message.device,
|
||||
progress_callback: forwardProgress,
|
||||
});
|
||||
return asrPromise;
|
||||
interface NavigatorWithGpu {
|
||||
gpu?: { requestAdapter(): Promise<unknown | null> };
|
||||
}
|
||||
|
||||
async function transcribe(audio: Float32Array): Promise<void> {
|
||||
if (asrPromise === null) {
|
||||
async function detectDevice(preference: DevicePreference): Promise<InferenceDevice> {
|
||||
if (preference === "wasm") return "wasm";
|
||||
try {
|
||||
const adapter = await (navigator as NavigatorWithGpu).gpu?.requestAdapter();
|
||||
return adapter ? "webgpu" : "wasm";
|
||||
} catch {
|
||||
return "wasm";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline lifecycle (load, switch, dispose)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActivePipeline {
|
||||
/** Identity of this load request; a different key means "switch models". */
|
||||
key: string;
|
||||
promise: Promise<AutomaticSpeechRecognitionPipeline>;
|
||||
}
|
||||
|
||||
let active: ActivePipeline | null = null;
|
||||
|
||||
function loadKey(message: LoadMessage): string {
|
||||
return JSON.stringify({
|
||||
modelId: message.modelId,
|
||||
device: message.device,
|
||||
dtype: message.dtype ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function createPipeline(
|
||||
message: LoadMessage,
|
||||
): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||
const detected = await detectDevice(message.device);
|
||||
// If WebGPU was selected but pipeline creation throws (driver quirks,
|
||||
// unsupported ops, out-of-memory), fall back to WASM automatically (D-004).
|
||||
const attempts: InferenceDevice[] =
|
||||
detected === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
|
||||
|
||||
let lastError: unknown;
|
||||
for (const device of attempts) {
|
||||
const dtype = message.dtype ?? resolveDefaultDtype(message.modelId, device);
|
||||
try {
|
||||
const asr = await pipeline("automatic-speech-recognition", message.modelId, {
|
||||
device,
|
||||
dtype,
|
||||
progress_callback: forwardProgress,
|
||||
});
|
||||
post({ type: "load-progress", status: "ready", device });
|
||||
return asr;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||
const key = loadKey(message);
|
||||
// Idempotent: repeated "load" messages for the same model/device/dtype reuse
|
||||
// the in-flight or completed pipeline.
|
||||
if (active?.key === key) return active.promise;
|
||||
|
||||
const previous = active;
|
||||
const promise = (async () => {
|
||||
if (previous) {
|
||||
// Dispose the old pipeline (frees WASM memory / GPU buffers) before
|
||||
// creating the new one, so both never coexist.
|
||||
try {
|
||||
const old = await previous.promise;
|
||||
await old.dispose();
|
||||
} catch {
|
||||
// The previous load itself failed — nothing to dispose.
|
||||
}
|
||||
}
|
||||
return createPipeline(message);
|
||||
})();
|
||||
active = { key, promise };
|
||||
promise.catch(() => {
|
||||
// Allow a clean retry after a failed load; ignore stale failures that
|
||||
// were already superseded by a newer load request.
|
||||
if (active?.key === key) active = null;
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function transcribe(message: TranscribeMessage): Promise<void> {
|
||||
if (active === null) {
|
||||
post({
|
||||
type: "error",
|
||||
stage: "transcribe",
|
||||
@@ -86,7 +189,7 @@ async function transcribe(audio: Float32Array): Promise<void> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const asr = await asrPromise;
|
||||
const asr = await active.promise;
|
||||
|
||||
const streamer = new WhisperTextStreamer(
|
||||
// The ASR pipeline for Whisper models always carries a WhisperTokenizer.
|
||||
@@ -97,12 +200,22 @@ async function transcribe(audio: Float32Array): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
const output = await asr(audio, {
|
||||
const options: Record<string, unknown> = {
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: true,
|
||||
streamer,
|
||||
});
|
||||
};
|
||||
// Only meaningful for multilingual models; the UI omits these for `.en`
|
||||
// variants. "auto" / undefined lets Whisper detect the language.
|
||||
if (message.language !== undefined && message.language !== "auto") {
|
||||
options.language = message.language;
|
||||
}
|
||||
if (message.task !== undefined) {
|
||||
options.task = message.task;
|
||||
}
|
||||
|
||||
const output = await asr(message.audio, options);
|
||||
|
||||
const single = (Array.isArray(output) ? output[0] : output) as {
|
||||
text?: string;
|
||||
@@ -129,12 +242,11 @@ scope.addEventListener("message", (event) => {
|
||||
switch (data.type) {
|
||||
case "load":
|
||||
load(data).catch((error: unknown) => {
|
||||
asrPromise = null; // allow retry after a failed download
|
||||
post({ type: "error", stage: "load", message: errorMessage(error) });
|
||||
});
|
||||
break;
|
||||
case "transcribe":
|
||||
transcribe(data.audio).catch((error: unknown) => {
|
||||
transcribe(data).catch((error: unknown) => {
|
||||
post({ type: "error", stage: "transcribe", message: errorMessage(error) });
|
||||
});
|
||||
break;
|
||||
|
||||
+83
-10
@@ -6,8 +6,29 @@
|
||||
* be unit-tested with `bun test`.
|
||||
*/
|
||||
|
||||
/** Devices supported by the worker. M1 is WASM-only; M2 adds "webgpu" (D-004). */
|
||||
export type InferenceDevice = "wasm";
|
||||
/** Devices the worker can actually run on (D-004). */
|
||||
export type InferenceDevice = "webgpu" | "wasm";
|
||||
|
||||
/**
|
||||
* What the UI may request. "auto" (the default) lets the worker probe
|
||||
* `navigator.gpu` and pick WebGPU with automatic WASM fallback (D-004).
|
||||
*/
|
||||
export type DevicePreference = InferenceDevice | "auto";
|
||||
|
||||
/** Quantization levels used by the curated catalog (D-006). */
|
||||
export type WhisperDtype = "fp32" | "fp16" | "q8" | "q4";
|
||||
|
||||
/**
|
||||
* Either one dtype for the whole model, or per-part dtypes — the shape
|
||||
* Transformers.js accepts. Whisper on WebGPU commonly needs a mixed setup
|
||||
* (e.g. fp16 encoder + q4 merged decoder), so per-part must be expressible.
|
||||
*/
|
||||
export type DtypeSpec =
|
||||
| WhisperDtype
|
||||
| { encoder_model: WhisperDtype; decoder_model_merged: WhisperDtype };
|
||||
|
||||
/** Whisper generation task (multilingual models only). */
|
||||
export type WhisperTask = "transcribe" | "translate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI → worker
|
||||
@@ -17,7 +38,13 @@ export interface LoadMessage {
|
||||
type: "load";
|
||||
/** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */
|
||||
modelId: string;
|
||||
device: InferenceDevice;
|
||||
/** Device preference; the worker resolves "auto" and reports the outcome. */
|
||||
device: DevicePreference;
|
||||
/**
|
||||
* Optional dtype override. When omitted the worker resolves the catalog
|
||||
* default for the device it actually selected (src/lib/models.ts).
|
||||
*/
|
||||
dtype?: DtypeSpec;
|
||||
}
|
||||
|
||||
export interface TranscribeMessage {
|
||||
@@ -27,6 +54,13 @@ export interface TranscribeMessage {
|
||||
* should be passed as a transferable for zero-copy handoff.
|
||||
*/
|
||||
audio: Float32Array;
|
||||
/**
|
||||
* ISO-ish Whisper language code (e.g. "en", "da"). Omit (or "auto") to let
|
||||
* Whisper detect the language. Only meaningful for multilingual models.
|
||||
*/
|
||||
language?: string;
|
||||
/** Only meaningful for multilingual models. Defaults to "transcribe". */
|
||||
task?: WhisperTask;
|
||||
}
|
||||
|
||||
export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
|
||||
@@ -38,7 +72,8 @@ export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
|
||||
/**
|
||||
* Model download / initialization progress. Mirrors the Transformers.js
|
||||
* `progress_callback` events we care about; `status: "ready"` marks the end
|
||||
* of a successful load.
|
||||
* of a successful load and reports the backend that actually got selected
|
||||
* (after WebGPU detection / fallback, D-004).
|
||||
*/
|
||||
export type LoadProgressMessage =
|
||||
| { type: "load-progress"; status: "initiate"; file: string }
|
||||
@@ -55,7 +90,7 @@ export type LoadProgressMessage =
|
||||
progress: number;
|
||||
}
|
||||
| { type: "load-progress"; status: "done"; file: string }
|
||||
| { type: "load-progress"; status: "ready" };
|
||||
| { type: "load-progress"; status: "ready"; device: InferenceDevice };
|
||||
|
||||
/** Incremental transcript fragment — append to the text streamed so far. */
|
||||
export interface PartialMessage {
|
||||
@@ -96,13 +131,45 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
const DEVICES: readonly string[] = ["webgpu", "wasm"];
|
||||
const DTYPES: readonly string[] = ["fp32", "fp16", "q8", "q4"];
|
||||
|
||||
export function isInferenceDevice(value: unknown): value is InferenceDevice {
|
||||
return typeof value === "string" && DEVICES.includes(value);
|
||||
}
|
||||
|
||||
export function isDevicePreference(value: unknown): value is DevicePreference {
|
||||
return value === "auto" || isInferenceDevice(value);
|
||||
}
|
||||
|
||||
export function isDtypeSpec(value: unknown): value is DtypeSpec {
|
||||
if (typeof value === "string") return DTYPES.includes(value);
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.encoder_model === "string" &&
|
||||
DTYPES.includes(value.encoder_model) &&
|
||||
typeof value.decoder_model_merged === "string" &&
|
||||
DTYPES.includes(value.decoder_model_merged)
|
||||
);
|
||||
}
|
||||
|
||||
export function isUiToWorkerMessage(value: unknown): value is UiToWorkerMessage {
|
||||
if (!isRecord(value)) return false;
|
||||
switch (value.type) {
|
||||
case "load":
|
||||
return typeof value.modelId === "string" && value.device === "wasm";
|
||||
return (
|
||||
typeof value.modelId === "string" &&
|
||||
isDevicePreference(value.device) &&
|
||||
(value.dtype === undefined || isDtypeSpec(value.dtype))
|
||||
);
|
||||
case "transcribe":
|
||||
return value.audio instanceof Float32Array;
|
||||
return (
|
||||
value.audio instanceof Float32Array &&
|
||||
(value.language === undefined || typeof value.language === "string") &&
|
||||
(value.task === undefined ||
|
||||
value.task === "transcribe" ||
|
||||
value.task === "translate")
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -125,7 +192,7 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
|
||||
typeof value.progress === "number"
|
||||
);
|
||||
case "ready":
|
||||
return true;
|
||||
return isInferenceDevice(value.device);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -159,9 +226,15 @@ export interface LoadProgressState {
|
||||
files: Record<string, FileProgress>;
|
||||
/** True once the pipeline reported ready. */
|
||||
ready: boolean;
|
||||
/** Backend the worker actually selected; null until ready (D-004). */
|
||||
device: InferenceDevice | null;
|
||||
}
|
||||
|
||||
export const initialLoadProgress: LoadProgressState = { files: {}, ready: false };
|
||||
export const initialLoadProgress: LoadProgressState = {
|
||||
files: {},
|
||||
ready: false,
|
||||
device: null,
|
||||
};
|
||||
|
||||
/** Fold one load-progress message into the accumulated state. Pure. */
|
||||
export function applyLoadProgress(
|
||||
@@ -169,7 +242,7 @@ export function applyLoadProgress(
|
||||
message: LoadProgressMessage,
|
||||
): LoadProgressState {
|
||||
if (message.status === "ready") {
|
||||
return { ...state, ready: true };
|
||||
return { ...state, ready: true, device: message.device };
|
||||
}
|
||||
const previous = state.files[message.file];
|
||||
const next: FileProgress = {
|
||||
|
||||
Reference in new issue
Block a user