Transcript export (.txt/.srt) and model storage manager (M6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5 committed 2026-07-14 19:41:47 +02:00
1 parent 0084e0435a
commit 8f2da530e2
9 files changed
+743 -12

No files matched your search

+120
View File
@@ -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<ReadonlyArray<{ url: string }>>;
match(
request: { url: string },
): Promise<{ blob(): Promise<{ size: number }> } | undefined>;
delete(request: { url: string }): Promise<boolean>;
}
/** The slice of `CacheStorage` the storage helpers use. */
export interface StorageCacheStorage {
keys(): Promise<string[]>;
open(name: string): Promise<StorageCache>;
}
/** 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` (`/<modelId>/`), 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<ModelStorageInfo[]> {
if (!cacheStorage) return [];
try {
const byModel = new Map<string, ModelStorageInfo>();
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<boolean> {
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;
}
}