Model: {modelId} · Engine:{" "}
diff --git a/src/components/StorageManager.tsx b/src/components/StorageManager.tsx
new file mode 100644
index 0000000..b5159da
--- /dev/null
+++ b/src/components/StorageManager.tsx
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ deleteModelFromCache,
+ getModelStorageInfo,
+ type ModelStorageInfo,
+} from "../lib/modelCache";
+import { MODELS, getModel } from "../lib/models";
+
+interface StorageManagerProps {
+ /** Downloaded-badge set from App — changes re-measure an open panel. */
+ downloaded: ReadonlySet;
+ /** True while transcribing/live — weights may be mid-read, so no deletes. */
+ disabled: boolean;
+ /** Called after a delete so App refreshes the downloaded badges (D-005). */
+ onChanged: () => void;
+}
+
+const MODEL_IDS = MODELS.map((model) => model.id);
+
+function formatBytes(bytes: number): string {
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
+ if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} kB`;
+ return `${bytes} B`;
+}
+
+/**
+ * Storage management panel (M6): lists models with cached weights and their
+ * exact on-disk size, with per-model delete. Collapsed by default — measuring
+ * reads every cached response body, so it only runs when the panel is opened
+ * (and again when the downloaded set changes or a model is deleted).
+ */
+export function StorageManager({ downloaded, disabled, onChanged }: StorageManagerProps) {
+ const [open, setOpen] = useState(false);
+ const [info, setInfo] = useState(null);
+ const [deleting, setDeleting] = useState(null);
+
+ const measure = useCallback(() => {
+ getModelStorageInfo(MODEL_IDS).then(setInfo, () => setInfo([]));
+ }, []);
+
+ useEffect(() => {
+ if (open) measure();
+ }, [open, downloaded, measure]);
+
+ async function handleDelete(modelId: string) {
+ setDeleting(modelId);
+ try {
+ await deleteModelFromCache(modelId);
+ measure();
+ // Refresh the picker's downloaded/offline badges too.
+ onChanged();
+ } finally {
+ setDeleting(null);
+ }
+ }
+
+ const totalBytes = (info ?? []).reduce((sum, entry) => sum + entry.bytes, 0);
+
+ return (
+ setOpen(event.currentTarget.open)}
+ >
+
+ Storage
+ {open && info !== null && info.length > 0 && (
+ — {formatBytes(totalBytes)} used
+ )}
+
+
+ Model weights cached in this browser. Deleting frees the space; the
+ model simply re-downloads the next time you use it.
+
+ {info === null ? (
+ Measuring…
+ ) : info.length === 0 ? (
+ No models downloaded yet.
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/TranscriptView.tsx b/src/components/TranscriptView.tsx
index 10e122e..5ba0439 100644
--- a/src/components/TranscriptView.tsx
+++ b/src/components/TranscriptView.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { exportFilename, transcriptToSrt, transcriptToTxt } from "../lib/export";
import type { TranscriptChunk } from "../worker/protocol";
interface TranscriptViewProps {
@@ -23,6 +24,21 @@ interface TranscriptViewProps {
progress?: { processedSeconds: number; totalSeconds: number } | null;
/** Text shown before the first words arrive. */
placeholder?: string;
+ /**
+ * Source file name the export filenames derive from ("talk.m4a" →
+ * "talk.txt"/"talk.srt"); null falls back to "transcript.*".
+ */
+ exportName?: string | null;
+}
+
+/** Trigger a browser download of `contents` as `filename`. */
+function download(contents: string, filename: string, mime: string) {
+ const url = URL.createObjectURL(new Blob([contents], { type: mime }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
}
function formatTimestamp(seconds: number): string {
@@ -40,6 +56,7 @@ export function TranscriptView({
activeLabel = "Transcribing…",
progress = null,
placeholder = "Listening for the first words…",
+ exportName = null,
}: TranscriptViewProps) {
const [copied, setCopied] = useState(false);
const text = result?.text ?? partialText;
@@ -73,14 +90,46 @@ export function TranscriptView({
{percent}%
)}
-
- {copied ? "Copied!" : "Copy"}
-
+
+ {result && (
+
+ download(
+ transcriptToTxt(result),
+ exportFilename(exportName, "txt"),
+ "text/plain",
+ )
+ }
+ >
+ .txt
+
+ )}
+ {result && result.chunks.length > 0 && (
+
+ download(
+ transcriptToSrt(result.chunks),
+ exportFilename(exportName, "srt"),
+ "application/x-subrip",
+ )
+ }
+ >
+ .srt
+
+ )}
+
+ {copied ? "Copied!" : "Copy"}
+
+
{showProgress && (
diff --git a/src/index.css b/src/index.css
index ea09faa..907be60 100644
--- a/src/index.css
+++ b/src/index.css
@@ -717,3 +717,103 @@ code {
animation: none !important;
}
}
+
+/* --------------------------------------------------------------------------
+ Storage management panel (M6): cached models, per-model delete.
+ -------------------------------------------------------------------------- */
+
+.storage-manager {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 0.75rem 1rem;
+}
+
+.storage-manager summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.storage-total {
+ color: var(--muted);
+ font-weight: normal;
+ font-variant-numeric: tabular-nums;
+}
+
+.storage-hint {
+ margin: 0.5rem 0 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.storage-list {
+ list-style: none;
+ margin: 0.75rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.storage-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.storage-model {
+ flex: 1 1 14rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+}
+
+.storage-name {
+ font-size: 0.95rem;
+}
+
+.storage-id {
+ font-size: 0.75rem;
+ color: var(--muted);
+ background: transparent;
+ padding: 0;
+ overflow-wrap: anywhere;
+}
+
+.storage-size {
+ color: var(--muted);
+ font-size: 0.85rem;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.storage-delete {
+ 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, color 0.15s;
+}
+
+.storage-delete:hover:enabled {
+ border-color: #8b3a3a;
+ color: #f2b8b5;
+}
+
+.storage-delete:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+/* --- Transcript export (M6) — .txt/.srt buttons grouped with Copy --- */
+
+.transcript-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
diff --git a/src/lib/export.ts b/src/lib/export.ts
new file mode 100644
index 0000000..fe31014
--- /dev/null
+++ b/src/lib/export.ts
@@ -0,0 +1,74 @@
+/**
+ * Transcript export to plain text and SubRip (M6). Pure functions — no DOM —
+ * so the formats are unit-testable under `bun test`; the download plumbing
+ * (Blob + anchor click) lives in TranscriptView.
+ */
+
+import type { TranscriptChunk } from "../worker/protocol";
+
+/**
+ * Cue length used when Whisper never closed the final segment
+ * (`timestamp[1] === null`) and there is no next chunk to borrow a start time
+ * from. Two seconds is roughly the floor of a spoken Whisper segment — long
+ * enough to read, short enough not to linger over silence.
+ */
+const FALLBACK_CUE_SECONDS = 2;
+
+/** Final transcript as plain text: trimmed, single trailing newline. */
+export function transcriptToTxt(result: { text: string }): string {
+ return `${result.text.trim()}\n`;
+}
+
+/** Seconds → SRT timestamp, e.g. 3661.5 → "01:01:01,500". */
+export function formatSrtTimestamp(seconds: number): string {
+ const totalMs = Math.max(0, Math.round(seconds * 1000));
+ const ms = totalMs % 1000;
+ const totalS = (totalMs - ms) / 1000;
+ const s = totalS % 60;
+ const totalM = (totalS - s) / 60;
+ const m = totalM % 60;
+ const h = (totalM - m) / 60;
+ const pad = (value: number, width: number) => String(value).padStart(width, "0");
+ return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
+}
+
+/**
+ * Timestamped chunks → SubRip. Empty-text chunks are skipped (they carry no
+ * caption). A null end time — Whisper sometimes never closes the last
+ * segment — borrows the next chunk's start, or falls back to
+ * start + FALLBACK_CUE_SECONDS on the final chunk; either way the cue never
+ * ends before it starts.
+ */
+export function transcriptToSrt(chunks: TranscriptChunk[]): string {
+ const cues: string[] = [];
+ const captioned = chunks.filter((chunk) => chunk.text.trim() !== "");
+ captioned.forEach((chunk, index) => {
+ const start = chunk.timestamp[0];
+ const end =
+ chunk.timestamp[1] ??
+ captioned[index + 1]?.timestamp[0] ??
+ start + FALLBACK_CUE_SECONDS;
+ cues.push(
+ [
+ String(index + 1),
+ `${formatSrtTimestamp(start)} --> ${formatSrtTimestamp(Math.max(start, end))}`,
+ chunk.text.trim(),
+ ].join("\n"),
+ );
+ });
+ return cues.length === 0 ? "" : `${cues.join("\n\n")}\n`;
+}
+
+/**
+ * Download filename for an export: the source file's base name with the
+ * extension swapped ("talk.m4a" → "talk.srt"), or "transcript." when no
+ * usable source name is known.
+ */
+export function exportFilename(sourceName: string | null | undefined, ext: string): string {
+ const trimmed = sourceName?.trim() ?? "";
+ if (trimmed === "") return `transcript.${ext}`;
+ const lastDot = trimmed.lastIndexOf(".");
+ // Dot at position 0 is a hidden file (".bashrc"), not an extension.
+ const base = lastDot > 0 ? trimmed.slice(0, lastDot) : trimmed;
+ return base === "" ? `transcript.${ext}` : `${base}.${ext}`;
+}
diff --git a/src/lib/modelCache.ts b/src/lib/modelCache.ts
index 17e5267..14d3c62 100644
--- a/src/lib/modelCache.ts
+++ b/src/lib/modelCache.ts
@@ -58,3 +58,123 @@ export async function getDownloadedModels(
return new Set();
}
}
+
+// ---------------------------------------------------------------------------
+// Storage management (M6): per-model size + delete.
+//
+// The functions below take an injectable Cache-Storage-shaped parameter so the
+// logic is unit-testable under `bun test` (no `caches` global there); the
+// structural types cover exactly the subset of the Cache API we touch, and the
+// real `caches` satisfies them. Scope is the transformers-cache only — the
+// service worker's `whisper-runtime-*` cache (onnxruntime .wasm, shared by all
+// models and versioned/cleaned by the SW itself) is deliberately not surfaced.
+// ---------------------------------------------------------------------------
+
+/** The slice of `Cache` the storage helpers use. */
+export interface StorageCache {
+ keys(): Promise>;
+ match(
+ request: { url: string },
+ ): Promise<{ blob(): Promise<{ size: number }> } | undefined>;
+ delete(request: { url: string }): Promise;
+}
+
+/** The slice of `CacheStorage` the storage helpers use. */
+export interface StorageCacheStorage {
+ keys(): Promise;
+ open(name: string): Promise;
+}
+
+/** Per-model cache usage, for the storage management panel. */
+export interface ModelStorageInfo {
+ modelId: string;
+ /** Number of cached files (weights + tokenizer/config aux files). */
+ fileCount: number;
+ /** Exact stored bytes, summed from the cached response bodies. */
+ bytes: number;
+}
+
+/**
+ * Pure matcher: which catalog model owns a cached URL? Same segment-boundary
+ * rule as `matchCachedModels` (`//`), so `.en` variants never match
+ * their multilingual sibling. Any file counts here — a partially downloaded
+ * model still occupies storage and must be listable/deletable.
+ */
+export function modelIdForUrl(
+ url: string,
+ modelIds: readonly string[],
+): string | undefined {
+ return modelIds.find((modelId) => url.includes(`/${modelId}/`));
+}
+
+function defaultCacheStorage(): StorageCacheStorage | undefined {
+ // The cast is sound: we only ever hand `match`/`delete` the `Request`
+ // objects that `keys()` itself returned (lib.dom types the params wider).
+ return typeof caches === "undefined"
+ ? undefined
+ : (caches as unknown as StorageCacheStorage);
+}
+
+/**
+ * Measure cached storage per model: file count and exact byte size (summed
+ * `blob().size` of every cached response). Models with nothing cached are
+ * omitted. Deliberately lazy/heavier than `getDownloadedModels` — it reads
+ * every cached body, so call it when the user opens the storage panel, not on
+ * every render. Returns [] where Cache Storage is unavailable.
+ */
+export async function getModelStorageInfo(
+ modelIds: readonly string[],
+ cacheStorage: StorageCacheStorage | undefined = defaultCacheStorage(),
+): Promise {
+ if (!cacheStorage) return [];
+ try {
+ const byModel = new Map();
+ for (const name of await cacheStorage.keys()) {
+ if (!name.includes("transformers")) continue;
+ const cache = await cacheStorage.open(name);
+ for (const request of await cache.keys()) {
+ const modelId = modelIdForUrl(request.url, modelIds);
+ if (modelId === undefined) continue;
+ const response = await cache.match(request);
+ if (!response) continue;
+ const size = (await response.blob()).size;
+ const info = byModel.get(modelId) ?? { modelId, fileCount: 0, bytes: 0 };
+ info.fileCount += 1;
+ info.bytes += size;
+ byModel.set(modelId, info);
+ }
+ }
+ // Catalog order, so the panel lists models the same way the picker does.
+ return modelIds
+ .map((modelId) => byModel.get(modelId))
+ .filter((info): info is ModelStorageInfo => info !== undefined);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Delete every cached file belonging to `modelId` (weights and aux files)
+ * from the transformers cache(s). Returns true if anything was deleted. The
+ * weights re-download on the model's next use — nothing but cache lives here.
+ */
+export async function deleteModelFromCache(
+ modelId: string,
+ cacheStorage: StorageCacheStorage | undefined = defaultCacheStorage(),
+): Promise {
+ if (!cacheStorage) return false;
+ try {
+ let deleted = false;
+ for (const name of await cacheStorage.keys()) {
+ if (!name.includes("transformers")) continue;
+ const cache = await cacheStorage.open(name);
+ for (const request of await cache.keys()) {
+ if (modelIdForUrl(request.url, [modelId]) !== modelId) continue;
+ if (await cache.delete(request)) deleted = true;
+ }
+ }
+ return deleted;
+ } catch {
+ return false;
+ }
+}
diff --git a/tests/export.test.ts b/tests/export.test.ts
new file mode 100644
index 0000000..7af743e
--- /dev/null
+++ b/tests/export.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from "bun:test";
+import {
+ exportFilename,
+ formatSrtTimestamp,
+ transcriptToSrt,
+ transcriptToTxt,
+} from "../src/lib/export";
+import type { TranscriptChunk } from "../src/worker/protocol";
+
+describe("transcriptToTxt", () => {
+ test("trims and appends a single trailing newline", () => {
+ expect(transcriptToTxt({ text: " Hello world. " })).toBe("Hello world.\n");
+ });
+
+ test("empty transcript becomes a bare newline", () => {
+ expect(transcriptToTxt({ text: "" })).toBe("\n");
+ });
+});
+
+describe("formatSrtTimestamp", () => {
+ test("zero", () => {
+ expect(formatSrtTimestamp(0)).toBe("00:00:00,000");
+ });
+
+ test("pads milliseconds to three digits", () => {
+ expect(formatSrtTimestamp(1.5)).toBe("00:00:01,500");
+ expect(formatSrtTimestamp(1.05)).toBe("00:00:01,050");
+ expect(formatSrtTimestamp(1.005)).toBe("00:00:01,005");
+ });
+
+ test("rolls over past one hour", () => {
+ expect(formatSrtTimestamp(3661.25)).toBe("01:01:01,250");
+ });
+
+ test("rounds sub-millisecond values", () => {
+ expect(formatSrtTimestamp(0.9996)).toBe("00:00:01,000");
+ });
+
+ test("clamps negatives to zero", () => {
+ expect(formatSrtTimestamp(-1)).toBe("00:00:00,000");
+ });
+});
+
+describe("transcriptToSrt", () => {
+ const chunk = (
+ text: string,
+ start: number,
+ end: number | null,
+ ): TranscriptChunk => ({ text, timestamp: [start, end] });
+
+ test("formats numbered cues separated by blank lines", () => {
+ const srt = transcriptToSrt([
+ chunk(" Hello.", 0, 2.5),
+ chunk(" World.", 2.5, 4),
+ ]);
+ expect(srt).toBe(
+ "1\n00:00:00,000 --> 00:00:02,500\nHello.\n" +
+ "\n" +
+ "2\n00:00:02,500 --> 00:00:04,000\nWorld.\n",
+ );
+ });
+
+ test("null end borrows the next chunk's start", () => {
+ const srt = transcriptToSrt([
+ chunk("One", 0, null),
+ chunk("Two", 3, 5),
+ ]);
+ expect(srt).toContain("1\n00:00:00,000 --> 00:00:03,000\nOne");
+ });
+
+ test("null end on the final chunk falls back to start + 2s", () => {
+ const srt = transcriptToSrt([chunk("Tail", 10, null)]);
+ expect(srt).toBe("1\n00:00:10,000 --> 00:00:12,000\nTail\n");
+ });
+
+ test("skips empty and whitespace-only chunks (numbering stays contiguous)", () => {
+ const srt = transcriptToSrt([
+ chunk(" ", 0, 1),
+ chunk("Kept", 1, null),
+ chunk("", 2, 3),
+ chunk("Also kept", 4, 5),
+ ]);
+ // The null end skips over the empty chunk to the next *captioned* start.
+ expect(srt).toBe(
+ "1\n00:00:01,000 --> 00:00:04,000\nKept\n" +
+ "\n" +
+ "2\n00:00:04,000 --> 00:00:05,000\nAlso kept\n",
+ );
+ });
+
+ test("cue never ends before it starts", () => {
+ // Out-of-order borrow: next captioned chunk starts before this one.
+ const srt = transcriptToSrt([chunk("A", 5, null), chunk("B", 4, 6)]);
+ expect(srt).toContain("00:00:05,000 --> 00:00:05,000");
+ });
+
+ test("no captioned chunks yields an empty string", () => {
+ expect(transcriptToSrt([])).toBe("");
+ expect(transcriptToSrt([chunk(" ", 0, 1)])).toBe("");
+ });
+});
+
+describe("exportFilename", () => {
+ test("swaps the extension", () => {
+ expect(exportFilename("talk.m4a", "srt")).toBe("talk.srt");
+ expect(exportFilename("talk.m4a", "txt")).toBe("talk.txt");
+ });
+
+ test("only the last dot counts", () => {
+ expect(exportFilename("2026.07.14 meeting.wav", "srt")).toBe(
+ "2026.07.14 meeting.srt",
+ );
+ });
+
+ test("no extension keeps the whole name", () => {
+ expect(exportFilename("recording", "txt")).toBe("recording.txt");
+ });
+
+ test("leading dot is a hidden file, not an extension", () => {
+ expect(exportFilename(".bashrc", "txt")).toBe(".bashrc.txt");
+ });
+
+ test("null, undefined, and blank fall back to transcript.*", () => {
+ expect(exportFilename(null, "srt")).toBe("transcript.srt");
+ expect(exportFilename(undefined, "txt")).toBe("transcript.txt");
+ expect(exportFilename(" ", "srt")).toBe("transcript.srt");
+ });
+});
diff --git a/tests/modelCache.test.ts b/tests/modelCache.test.ts
index 6352e80..2a1cfbb 100644
--- a/tests/modelCache.test.ts
+++ b/tests/modelCache.test.ts
@@ -1,8 +1,16 @@
import { describe, test, expect } from "bun:test";
-import { matchCachedModels } from "../src/lib/modelCache";
+import {
+ deleteModelFromCache,
+ getModelStorageInfo,
+ matchCachedModels,
+ modelIdForUrl,
+ type StorageCache,
+ type StorageCacheStorage,
+} from "../src/lib/modelCache";
// Pure URL matching (D-005 downloaded badge); the `caches` wrapper is
-// browser-only and exercised manually.
+// browser-only and exercised manually. The M6 storage helpers take an
+// injectable CacheStorage-shaped object, mocked below.
const TINY = "onnx-community/whisper-tiny.en";
const BASE = "onnx-community/whisper-base";
@@ -47,3 +55,122 @@ describe("matchCachedModels", () => {
);
});
});
+
+describe("modelIdForUrl", () => {
+ test("matches on the /modelId/ segment, any file type", () => {
+ expect(modelIdForUrl(hubUrl(TINY, "tokenizer.json"), [TINY, BASE])).toBe(TINY);
+ expect(modelIdForUrl(hubUrl(BASE, "onnx/encoder_model_q4.onnx"), [TINY, BASE])).toBe(
+ BASE,
+ );
+ });
+
+ test("'.en' variant URLs never match the multilingual sibling", () => {
+ const url = hubUrl(TINY, "onnx/encoder_model.onnx");
+ expect(modelIdForUrl(url, ["onnx-community/whisper-tiny"])).toBeUndefined();
+ });
+
+ test("unmatched URLs yield undefined", () => {
+ expect(modelIdForUrl("https://example.com/other.bin", [TINY])).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// M6 storage helpers, against a mocked Cache Storage
+// ---------------------------------------------------------------------------
+
+/** Mutable mock: cache name → (url → body size in bytes). */
+function mockCacheStorage(
+ data: Record>,
+): StorageCacheStorage {
+ const stores = new Map>(
+ Object.entries(data).map(([name, entries]) => [
+ name,
+ new Map(Object.entries(entries)),
+ ]),
+ );
+ function openCache(name: string): StorageCache {
+ const entries = stores.get(name) ?? new Map();
+ return {
+ keys: async () => [...entries.keys()].map((url) => ({ url })),
+ match: async (request) => {
+ const size = entries.get(request.url);
+ if (size === undefined) return undefined;
+ return { blob: async () => new Blob([new Uint8Array(size)]) };
+ },
+ delete: async (request) => entries.delete(request.url),
+ };
+ }
+ return {
+ keys: async () => [...stores.keys()],
+ open: async (name) => openCache(name),
+ };
+}
+
+describe("getModelStorageInfo", () => {
+ test("sums bytes and counts files per model, in catalog order", async () => {
+ const storage = mockCacheStorage({
+ "transformers-cache": {
+ [hubUrl(BASE, "onnx/encoder_model_q8.onnx")]: 2_000,
+ [hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
+ [hubUrl(TINY, "onnx/decoder_model_merged_quantized.onnx")]: 3_000,
+ [hubUrl(TINY, "tokenizer.json")]: 50,
+ },
+ });
+ expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
+ { modelId: TINY, fileCount: 3, bytes: 4_050 },
+ { modelId: BASE, fileCount: 1, bytes: 2_000 },
+ ]);
+ });
+
+ test("ignores non-transformers caches and unmatched URLs", async () => {
+ const storage = mockCacheStorage({
+ "whisper-runtime-v1": { "https://example.com/ort/ort-wasm.wasm": 9_999_999 },
+ "transformers-cache": {
+ [hubUrl(TINY, "config.json")]: 10,
+ "https://example.com/unrelated.bin": 123,
+ },
+ });
+ expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
+ { modelId: TINY, fileCount: 1, bytes: 10 },
+ ]);
+ });
+
+ test("returns [] with no cache storage (non-browser contexts)", async () => {
+ expect(await getModelStorageInfo([TINY], undefined)).toEqual([]);
+ });
+});
+
+describe("deleteModelFromCache", () => {
+ test("removes only the target model's entries and reports deletion", async () => {
+ const storage = mockCacheStorage({
+ "transformers-cache": {
+ [hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
+ [hubUrl(TINY, "tokenizer.json")]: 50,
+ [hubUrl(BASE, "onnx/encoder_model_q8.onnx")]: 2_000,
+ },
+ });
+ expect(await deleteModelFromCache(TINY, storage)).toBe(true);
+ expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
+ { modelId: BASE, fileCount: 1, bytes: 2_000 },
+ ]);
+ });
+
+ test("does not delete the '.en' sibling's files", async () => {
+ const multilingualTiny = "onnx-community/whisper-tiny";
+ const storage = mockCacheStorage({
+ "transformers-cache": {
+ [hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
+ },
+ });
+ expect(await deleteModelFromCache(multilingualTiny, storage)).toBe(false);
+ expect(await getModelStorageInfo([TINY], storage)).toEqual([
+ { modelId: TINY, fileCount: 1, bytes: 1_000 },
+ ]);
+ });
+
+ test("returns false when nothing matched or storage is unavailable", async () => {
+ const storage = mockCacheStorage({ "transformers-cache": {} });
+ expect(await deleteModelFromCache(TINY, storage)).toBe(false);
+ expect(await deleteModelFromCache(TINY, undefined)).toBe(false);
+ });
+});