181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
/**
|
|
* "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();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
}
|