From 87352857b1589ce5ed86c3cd9390f77929b67a55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 09:57:04 +0000 Subject: [PATCH 01/11] Add weightlift: framework-agnostic in-browser ML model load progress Extract the multi-file download progress aggregation used by the Whisper worker into a publishable zero-dep TypeScript package (weightlift) with Transformers.js / WebLLM adapters, a worker postMessage bridge, and a thin React useSyncExternalStore hook. Wire the transcription worker to consume it and cover the core with unit tests. Co-authored-by: Wassim Gharbi --- .github/workflows/ci.yml | 3 + .gitignore | 1 + next.config.ts | 2 + package-lock.json | 26 ++ package.json | 8 +- packages/weightlift/LICENSE | 21 ++ packages/weightlift/README.md | 142 +++++++++++ packages/weightlift/package.json | 82 +++++++ packages/weightlift/src/adapters/index.ts | 10 + .../weightlift/src/adapters/transformers.ts | 91 +++++++ packages/weightlift/src/adapters/webllm.ts | 66 ++++++ packages/weightlift/src/index.ts | 56 +++++ packages/weightlift/src/react.ts | 53 +++++ packages/weightlift/src/reduce.ts | 223 ++++++++++++++++++ packages/weightlift/src/store.ts | 164 +++++++++++++ packages/weightlift/src/types.ts | 70 ++++++ packages/weightlift/src/worker.ts | 179 ++++++++++++++ packages/weightlift/tests/store-test.ts | 172 ++++++++++++++ packages/weightlift/tests/worker-test.ts | 84 +++++++ packages/weightlift/tsconfig.build.json | 13 + packages/weightlift/tsconfig.json | 18 ++ tsconfig.json | 9 +- workers/transcription.worker.ts | 62 ++--- 23 files changed, 1505 insertions(+), 50 deletions(-) create mode 100644 packages/weightlift/LICENSE create mode 100644 packages/weightlift/README.md create mode 100644 packages/weightlift/package.json create mode 100644 packages/weightlift/src/adapters/index.ts create mode 100644 packages/weightlift/src/adapters/transformers.ts create mode 100644 packages/weightlift/src/adapters/webllm.ts create mode 100644 packages/weightlift/src/index.ts create mode 100644 packages/weightlift/src/react.ts create mode 100644 packages/weightlift/src/reduce.ts create mode 100644 packages/weightlift/src/store.ts create mode 100644 packages/weightlift/src/types.ts create mode 100644 packages/weightlift/src/worker.ts create mode 100644 packages/weightlift/tests/store-test.ts create mode 100644 packages/weightlift/tests/worker-test.ts create mode 100644 packages/weightlift/tsconfig.build.json create mode 100644 packages/weightlift/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54b109b..ccad791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Lint run: npm run lint + - name: Test weightlift + run: npm run test:weightlift + - name: Typecheck (web) run: | # next-env.d.ts is gitignored; generate it (and route types) before tsc. diff --git a/.gitignore b/.gitignore index 984463f..1f16148 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # electron compile + electron-builder packages /electron-dist/ /dist/ +packages/weightlift/dist/ # misc .DS_Store diff --git a/next.config.ts b/next.config.ts index f08d0fb..6e78ec5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,8 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const nextConfig: NextConfig = { reactStrictMode: true, + // Local workspace package (packages/weightlift) — compile from source/dist. + transpilePackages: ["weightlift"], ...(isExport ? { output: "export" as const, diff --git a/package-lock.json b/package-lock.json index ac8d638..198a985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "rescript", "version": "1.0.9", "hasInstallScript": true, + "workspaces": [ + "packages/*" + ], "dependencies": { "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", @@ -21,6 +24,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", + "weightlift": "*", "zustand": "^5.0.14" }, "devDependencies": { @@ -10471,6 +10475,10 @@ "tslib": "^2.8.1" } }, + "node_modules/weightlift": { + "resolved": "packages/weightlift", + "link": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10731,6 +10739,24 @@ "optional": true } } + }, + "packages/weightlift": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/react": "^19", + "react": "19.2.4", + "tsx": "^4.22.4", + "typescript": "^5" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 85bd298..ed43940 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,18 @@ "type": "git", "url": "git+https://github.com/wassgha/rescript.git" }, + "workspaces": [ + "packages/*" + ], "main": "./electron-dist/main.js", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", - "postinstall": "node scripts/copy-assets.mjs", + "test:weightlift": "npm run test -w weightlift", + "build:weightlift": "npm run build -w weightlift", + "postinstall": "npm run build:weightlift && node scripts/copy-assets.mjs", "build:electron": "node scripts/build-electron.mjs", "typecheck:electron": "tsc -p electron/tsconfig.json --noEmit", "electron:dev": "npm run build:electron && concurrently -k -n next,electron -c cyan,magenta \"next dev\" \"node scripts/electron-dev.mjs\"", @@ -42,6 +47,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", + "weightlift": "*", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/packages/weightlift/LICENSE b/packages/weightlift/LICENSE new file mode 100644 index 0000000..81c0264 --- /dev/null +++ b/packages/weightlift/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wassim Gharbi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/weightlift/README.md b/packages/weightlift/README.md new file mode 100644 index 0000000..7d846d3 --- /dev/null +++ b/packages/weightlift/README.md @@ -0,0 +1,142 @@ +# weightlift + +Framework-agnostic progress state for **in-browser ML model loading**. + +Transformers.js, WebLLM, and whisper.cpp WASM each expose their own progress +callbacks. Everyone ends up hand-rolling the same ~100 lines: aggregate +multi-file downloads into one percentage, tolerate missing `Content-Length`, +dedupe concurrent loads, and shuttle events out of a web worker. + +**weightlift** is that thin layer — a zero-dependency TypeScript core plus a +15-line React adapter. + +```ts +import { Weightlift } from "weightlift"; +import { transformersAdapter } from "weightlift/adapters/transformers"; + +const wl = new Weightlift(); +wl.subscribe((s) => { + console.log(s.status, s.percent, s.message); +}); + +wl.start("Downloading speech model…"); +await pipeline("automatic-speech-recognition", modelId, { + progress_callback: transformersAdapter(wl), +}); +wl.ready(); +``` + +## Install + +```bash +npm install weightlift +``` + +React is an **optional** peer dependency — only needed for `weightlift/react`. + +## Package exports + +| Import | What | +| --- | --- | +| `weightlift` | Core store, reducer, loader helpers, adapter re-exports | +| `weightlift/react` | `useWeightlift` / `useWeightliftStore` (`useSyncExternalStore`) | +| `weightlift/adapters/transformers` | `transformersAdapter()` for `progress_callback` | +| `weightlift/adapters/webllm` | `webllmAdapter()` for `initProgressCallback` | +| `weightlift/worker` | `createWorkerReporter` / `attachWorker` postMessage bridge | + +## State shape + +```ts +interface WeightliftState { + status: "idle" | "loading" | "ready" | "error"; + message: string; + files: Record; + loadedBytes: number; + totalBytes: number | null; + percent: number | null; // 0..1, or null when indeterminate + indeterminate: boolean; + error: Error | null; +} +``` + +Files download in parallel, and totals are sometimes missing — when no usable +byte total is known, `percent` is `null` and `indeterminate` is `true`. + +## React + +```tsx +import { useMemo } from "react"; +import { Weightlift } from "weightlift"; +import { useWeightlift } from "weightlift/react"; + +function ModelBar({ weightlift }: { weightlift: Weightlift }) { + const { percent, message, indeterminate, isLoading } = useWeightlift(weightlift); + if (!isLoading) return null; + return ( +
+

{message}

+ +
+ ); +} +``` + +## Web worker + +Model loads belong in a worker; the UI lives on the main thread. weightlift +ships the glue: + +```ts +// worker.ts +import { Weightlift } from "weightlift"; +import { transformersAdapter } from "weightlift/adapters/transformers"; +import { createWorkerReporter } from "weightlift/worker"; + +const wl = new Weightlift(); +const reporter = createWorkerReporter(self, wl, { mode: "event" }); +wl.start("Downloading…"); + +await pipeline("automatic-speech-recognition", id, { + progress_callback: transformersAdapter(reporter), +}); +wl.ready(); +``` + +```ts +// main.ts +import { Weightlift } from "weightlift"; +import { attachWorker } from "weightlift/worker"; +import { useWeightlift } from "weightlift/react"; + +const wl = new Weightlift(); +attachWorker(worker, wl); +``` + +## Deduping loads + +```ts +import { createModelRegistry } from "weightlift"; + +const registry = createModelRegistry(); + +export function getAsr(modelId: string) { + return registry + .get(modelId, async (wl) => { + wl.start("Downloading…"); + return pipeline("automatic-speech-recognition", modelId, { + progress_callback: transformersAdapter(wl), + }); + }) + .load(); +} +``` + +## Why not React-first? + +Hooks cannot run inside web workers, and that is where model loading happens. +A vanilla subscription store + `useSyncExternalStore` binding is the same +pattern Zustand, TanStack Query, and Jotai use — one core, many frameworks. + +## License + +MIT diff --git a/packages/weightlift/package.json b/packages/weightlift/package.json new file mode 100644 index 0000000..ffe0243 --- /dev/null +++ b/packages/weightlift/package.json @@ -0,0 +1,82 @@ +{ + "name": "weightlift", + "version": "0.1.0", + "description": "Framework-agnostic progress state for in-browser ML model loading (Transformers.js, WebLLM, and friends).", + "license": "MIT", + "author": { + "name": "wassgha", + "email": "wassgha@gmail.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wassgha/rescript.git", + "directory": "packages/weightlift" + }, + "keywords": [ + "ml", + "transformers.js", + "webllm", + "whisper", + "onnx", + "progress", + "model-loading", + "web-worker" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js", + "default": "./dist/react.js" + }, + "./adapters/transformers": { + "types": "./dist/adapters/transformers.d.ts", + "import": "./dist/adapters/transformers.js", + "default": "./dist/adapters/transformers.js" + }, + "./adapters/webllm": { + "types": "./dist/adapters/webllm.d.ts", + "import": "./dist/adapters/webllm.js", + "default": "./dist/adapters/webllm.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "import": "./dist/worker.js", + "default": "./dist/worker.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "tsx --test tests/**/*-test.ts", + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19", + "react": "19.2.4", + "tsx": "^4.22.4", + "typescript": "^5" + } +} diff --git a/packages/weightlift/src/adapters/index.ts b/packages/weightlift/src/adapters/index.ts new file mode 100644 index 0000000..d280de0 --- /dev/null +++ b/packages/weightlift/src/adapters/index.ts @@ -0,0 +1,10 @@ +export { + transformersAdapter, + transformersEvent, + type TransformersProgressInfo, +} from "./transformers.js"; +export { + webllmAdapter, + webllmEvent, + type WebLLMInitProgressReport, +} from "./webllm.js"; diff --git a/packages/weightlift/src/adapters/transformers.ts b/packages/weightlift/src/adapters/transformers.ts new file mode 100644 index 0000000..2e7e381 --- /dev/null +++ b/packages/weightlift/src/adapters/transformers.ts @@ -0,0 +1,91 @@ +import type { WeightliftEvent } from "../types.js"; + +/** Anything that can accept normalized events (store or worker reporter). */ +export interface EventSink { + dispatch(event: WeightliftEvent): void; +} + +/** + * Subset of the transformers.js `ProgressInfo` union that carries download + * progress. Kept structural so weightlift does not depend on + * `@huggingface/transformers` at runtime. + * + * @see https://huggingface.co/docs/transformers.js/guides/progress + */ +export interface TransformersProgressInfo { + status?: string; + file?: string; + name?: string; + loaded?: number; + total?: number; + progress?: number; +} + +/** + * Convert a transformers.js `progress_callback` argument into a normalized + * {@link WeightliftEvent}, or `null` if the event should be ignored. + */ +export function transformersEvent( + p: TransformersProgressInfo +): WeightliftEvent | null { + const status = p.status; + + if (status === "initiate" && p.file) { + return { type: "initiate", file: p.file }; + } + + if (status === "progress_total") { + const total = p.total ?? 0; + if (!(total > 0)) return null; + const loaded = + typeof p.progress === "number" + ? (p.progress / 100) * total + : (p.loaded ?? 0); + return { type: "progress_total", loaded, total }; + } + + if (status === "progress" && p.file) { + return { + type: "progress", + file: p.file, + loaded: p.loaded ?? 0, + total: p.total, + }; + } + + if (status === "done" && p.file) { + return { type: "done", file: p.file }; + } + + if (status === "ready") { + return { type: "ready" }; + } + + return null; +} + +/** + * Build a `progress_callback` suitable for `pipeline()` / `from_pretrained()`. + * + * ```ts + * const wl = new Weightlift(); + * wl.start("Downloading speech model…"); + * await pipeline("automatic-speech-recognition", modelId, { + * progress_callback: transformersAdapter(wl), + * }); + * wl.ready(); + * ``` + */ +export function transformersAdapter( + sink: EventSink, + options?: { message?: string } +): (progress: TransformersProgressInfo) => void { + if (options?.message) { + sink.dispatch({ type: "message", message: options.message }); + } + + return (progress) => { + const event = transformersEvent(progress); + if (event) sink.dispatch(event); + }; +} diff --git a/packages/weightlift/src/adapters/webllm.ts b/packages/weightlift/src/adapters/webllm.ts new file mode 100644 index 0000000..0d8b810 --- /dev/null +++ b/packages/weightlift/src/adapters/webllm.ts @@ -0,0 +1,66 @@ +import type { WeightliftEvent } from "../types.js"; + +/** Anything that can accept normalized events (store or worker reporter). */ +export interface EventSink { + dispatch(event: WeightliftEvent): void; +} + +/** + * Shape of WebLLM / MLC `InitProgressReport`. + * @see https://webllm.mlc.ai/ + */ +export interface WebLLMInitProgressReport { + /** 0..1 progress fraction. */ + progress: number; + /** Human-readable status text. */ + text?: string; + timeElapsed?: number; +} + +/** + * Convert a WebLLM `initProgressCallback` report into a normalized event. + * + * WebLLM exposes a single aggregate fraction (not per-file bytes), so this + * maps onto `progress_total` with a synthetic 100-unit total. + */ +export function webllmEvent( + report: WebLLMInitProgressReport +): WeightliftEvent { + const progress = Math.min(1, Math.max(0, report.progress || 0)); + const total = 100; + const loaded = progress * total; + if (progress >= 1) { + return { + type: "progress_total", + loaded: total, + total, + message: report.text, + }; + } + return { + type: "progress_total", + loaded, + total, + message: report.text, + }; +} + +/** + * Build an `initProgressCallback` for WebLLM's `CreateMLCEngine` / similar. + * + * ```ts + * const wl = new Weightlift(); + * wl.start("Loading LLM…"); + * const engine = await CreateMLCEngine(modelId, { + * initProgressCallback: webllmAdapter(wl), + * }); + * wl.ready(); + * ``` + */ +export function webllmAdapter( + sink: EventSink +): (report: WebLLMInitProgressReport) => void { + return (report) => { + sink.dispatch(webllmEvent(report)); + }; +} diff --git a/packages/weightlift/src/index.ts b/packages/weightlift/src/index.ts new file mode 100644 index 0000000..a270a23 --- /dev/null +++ b/packages/weightlift/src/index.ts @@ -0,0 +1,56 @@ +/** + * weightlift — framework-agnostic progress state for in-browser ML model loading. + * + * @packageDocumentation + * + * ```ts + * import { Weightlift } from "weightlift"; + * import { transformersAdapter } from "weightlift/adapters/transformers"; + * + * const wl = new Weightlift(); + * wl.subscribe((s) => console.log(s.percent, s.message)); + * wl.start("Downloading model…"); + * + * await pipeline("feature-extraction", modelId, { + * progress_callback: transformersAdapter(wl), + * }); + * wl.ready(); + * ``` + */ + +export { + Weightlift, + createModelLoader, + createModelRegistry, + type Unsubscribe, +} from "./store.js"; + +export { + reduce, + createReduceContext, + type ReduceContext, +} from "./reduce.js"; + +export { + INITIAL_STATE, + type LoadStatus, + type FileProgress, + type WeightliftState, + type WeightliftEvent, + type WeightliftListener, +} from "./types.js"; + +// Adapters are also available via dedicated entry points: +// weightlift/adapters/transformers +// weightlift/adapters/webllm +// Re-exported here for convenience in simple setups. +export { + transformersAdapter, + transformersEvent, + type TransformersProgressInfo, +} from "./adapters/transformers.js"; +export { + webllmAdapter, + webllmEvent, + type WebLLMInitProgressReport, +} from "./adapters/webllm.js"; diff --git a/packages/weightlift/src/react.ts b/packages/weightlift/src/react.ts new file mode 100644 index 0000000..1fa4cb1 --- /dev/null +++ b/packages/weightlift/src/react.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useMemo, useSyncExternalStore } from "react"; +import { Weightlift } from "./store.js"; +import type { WeightliftState } from "./types.js"; + +export type { WeightliftState, LoadStatus, FileProgress } from "./types.js"; +export { + Weightlift, + createModelLoader, + createModelRegistry, +} from "./store.js"; + +/** + * Subscribe a React component to a {@link Weightlift} store. + * + * Built on `useSyncExternalStore` — the same pattern Zustand / TanStack Query + * use for their vanilla cores. Safe for concurrent rendering. + * + * ```tsx + * const wl = useMemo(() => new Weightlift(), []); + * const { percent, message, isLoading } = useWeightlift(wl); + * ``` + */ +export function useWeightlift(weightlift: Weightlift): WeightliftState & { + isLoading: boolean; + isReady: boolean; + isIdle: boolean; + hasError: boolean; +} { + const state = useSyncExternalStore( + weightlift.subscribe, + weightlift.getSnapshot, + weightlift.getServerSnapshot + ); + + return { + ...state, + isLoading: state.status === "loading", + isReady: state.status === "ready", + isIdle: state.status === "idle", + hasError: state.status === "error", + }; +} + +/** + * Create a stable {@link Weightlift} instance for the lifetime of the component. + * Pass an optional `key` to reset when the model id changes. + */ +export function useWeightliftStore(key?: string): Weightlift { + // key intentionally part of deps so switching models gets a fresh store. + return useMemo(() => new Weightlift(), [key]); +} diff --git a/packages/weightlift/src/reduce.ts b/packages/weightlift/src/reduce.ts new file mode 100644 index 0000000..67a3b83 --- /dev/null +++ b/packages/weightlift/src/reduce.ts @@ -0,0 +1,223 @@ +import { + INITIAL_STATE, + type FileProgress, + type WeightliftEvent, + type WeightliftState, +} from "./types.js"; + +interface InternalTotals { + /** Prefer runtime aggregate events when the adapter emits them. */ + useTotalEvents: boolean; + /** Highest percent observed; only moves forward except when totals grow. */ + best: number; + lastTotal: number; +} + +export interface ReduceContext { + totals: InternalTotals; +} + +export function createReduceContext(): ReduceContext { + return { + totals: { useTotalEvents: false, best: 0, lastTotal: 0 }, + }; +} + +function cloneFiles( + files: Record +): Record { + const next: Record = {}; + for (const [k, v] of Object.entries(files)) { + next[k] = { ...v }; + } + return next; +} + +function sumFiles(files: Record): { + loadedBytes: number; + totalBytes: number | null; +} { + // Only files that reported a Content-Length contribute to the percentage. + // Files without a total are ignored for the bar (matches how browsers / + // transformers.js UIs typically behave) — if *no* file has a total, the + // store stays indeterminate. + let loadedBytes = 0; + let totalBytes = 0; + let anyTotal = false; + + for (const f of Object.values(files)) { + if (f.total != null && f.total > 0) { + anyTotal = true; + loadedBytes += Math.min(f.loaded, f.total); + totalBytes += f.total; + } else if (f.status === "done") { + // Done with unknown total: count bytes we saw so the bar can finish. + loadedBytes += f.loaded; + totalBytes += f.loaded; + if (f.loaded > 0) anyTotal = true; + } + } + + return { + loadedBytes, + totalBytes: anyTotal ? totalBytes : null, + }; +} + +/** + * Apply a monotonic percent update. When the known byte total grows mid-load + * (common when more files are discovered), rescale `best` so the bar can move + * backwards honestly instead of sticking at an inflated value. + */ +function reportPercent( + ctx: ReduceContext, + loaded: number, + total: number +): { percent: number | null; indeterminate: boolean } { + if (!(total > 0)) { + return { percent: null, indeterminate: true }; + } + const { totals } = ctx; + if (total > totals.lastTotal && totals.lastTotal > 0 && totals.best > 0) { + totals.best *= totals.lastTotal / total; + } + totals.lastTotal = total; + totals.best = Math.max(totals.best, Math.min(1, loaded / total)); + return { percent: totals.best, indeterminate: false }; +} + +function withDerived( + state: WeightliftState, + ctx: ReduceContext, + patch: Partial +): WeightliftState { + const next: WeightliftState = { ...state, ...patch }; + + if (!ctx.totals.useTotalEvents) { + const { loadedBytes, totalBytes } = sumFiles(next.files); + next.loadedBytes = loadedBytes; + next.totalBytes = totalBytes; + if (totalBytes != null && totalBytes > 0) { + const reported = reportPercent(ctx, loadedBytes, totalBytes); + next.percent = reported.percent; + next.indeterminate = reported.indeterminate; + } else if (Object.keys(next.files).length === 0) { + next.percent = null; + next.indeterminate = true; + } else { + next.percent = null; + next.indeterminate = true; + } + } + + return next; +} + +/** Pure reducer: previous state + event → next state. */ +export function reduce( + state: WeightliftState, + event: WeightliftEvent, + ctx: ReduceContext +): WeightliftState { + switch (event.type) { + case "reset": + ctx.totals = { useTotalEvents: false, best: 0, lastTotal: 0 }; + return { ...INITIAL_STATE }; + + case "start": + return withDerived( + state, + ctx, + { + status: "loading", + message: event.message ?? state.message, + error: null, + } + ); + + case "message": + return { ...state, message: event.message }; + + case "initiate": { + const files = cloneFiles(state.files); + if (!files[event.file]) { + files[event.file] = { loaded: 0, total: null, status: "pending" }; + } + return withDerived(state, ctx, { + status: state.status === "idle" ? "loading" : state.status, + files, + error: null, + }); + } + + case "progress": { + // Once the runtime is emitting aggregate totals, ignore per-file noise. + if (ctx.totals.useTotalEvents) { + return state; + } + const files = cloneFiles(state.files); + const prev = files[event.file]; + const total = + event.total != null && event.total > 0 + ? event.total + : (prev?.total ?? null); + files[event.file] = { + loaded: event.loaded, + total, + status: "downloading", + }; + return withDerived(state, ctx, { + status: "loading", + files, + error: null, + }); + } + + case "progress_total": { + ctx.totals.useTotalEvents = true; + const reported = reportPercent(ctx, event.loaded, event.total); + return { + ...state, + status: "loading", + message: event.message ?? state.message, + loadedBytes: event.loaded, + totalBytes: event.total, + percent: reported.percent, + indeterminate: reported.indeterminate, + error: null, + }; + } + + case "done": { + const files = cloneFiles(state.files); + const prev = files[event.file]; + files[event.file] = { + loaded: prev?.total ?? prev?.loaded ?? 0, + total: prev?.total ?? prev?.loaded ?? null, + status: "done", + }; + return withDerived(state, ctx, { files }); + } + + case "ready": + return { + ...state, + status: "ready", + message: event.message ?? state.message, + percent: state.indeterminate && state.percent == null ? 1 : (state.percent ?? 1), + indeterminate: false, + error: null, + }; + + case "error": + return { + ...state, + status: "error", + message: event.message ?? event.error.message, + error: event.error, + }; + + default: + return state; + } +} diff --git a/packages/weightlift/src/store.ts b/packages/weightlift/src/store.ts new file mode 100644 index 0000000..8dfc9db --- /dev/null +++ b/packages/weightlift/src/store.ts @@ -0,0 +1,164 @@ +import { reduce, createReduceContext, type ReduceContext } from "./reduce.js"; +import { + INITIAL_STATE, + type WeightliftEvent, + type WeightliftListener, + type WeightliftState, +} from "./types.js"; + +export type Unsubscribe = () => void; + +/** + * Framework-agnostic model-load progress store. + * + * Works in vanilla JS, Vue, Svelte, and — importantly — inside a web worker, + * where model loading actually happens. Feed it normalized {@link WeightliftEvent}s + * (directly or via a runtime adapter); subscribe for a single aggregated state. + */ +export class Weightlift { + #state: WeightliftState = { ...INITIAL_STATE, files: {} }; + #listeners = new Set(); + #ctx: ReduceContext = createReduceContext(); + + /** Current snapshot (referentially stable until the next event). */ + getSnapshot = (): WeightliftState => this.#state; + + /** SSR / server snapshot — always the idle initial state. */ + getServerSnapshot = (): WeightliftState => INITIAL_STATE; + + /** + * Subscribe to state changes. Returns an unsubscribe function. + * Compatible with `useSyncExternalStore`. + */ + subscribe = (listener: WeightliftListener): Unsubscribe => { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + }; + + /** Dispatch a normalized progress event. */ + dispatch(event: WeightliftEvent): void { + const next = reduce(this.#state, event, this.#ctx); + if (next === this.#state) return; + this.#state = next; + for (const listener of this.#listeners) { + listener(next); + } + } + + /** Convenience: mark loading started with an optional label. */ + start(message?: string): void { + this.dispatch({ type: "start", message }); + } + + /** Convenience: mark the model ready. */ + ready(message?: string): void { + this.dispatch({ type: "ready", message }); + } + + /** Convenience: record a failure. */ + fail(error: unknown, message?: string): void { + const err = error instanceof Error ? error : new Error(String(error)); + this.dispatch({ type: "error", error: err, message }); + } + + /** Reset to idle (e.g. before loading a different model). */ + reset(): void { + this.dispatch({ type: "reset" }); + } + + /** + * Shorthand getters matching the common `isLoading` / `isReady` UI pattern. + */ + get status(): WeightliftState["status"] { + return this.#state.status; + } + + get isLoading(): boolean { + return this.#state.status === "loading"; + } + + get isReady(): boolean { + return this.#state.status === "ready"; + } + + get isIdle(): boolean { + return this.#state.status === "idle"; + } + + get hasError(): boolean { + return this.#state.status === "error"; + } +} + +/** + * Deduplicating loader: concurrent `load()` calls share one promise and one + * {@link Weightlift} store. Keyed externally by the caller (model id, etc.). + */ +export function createModelLoader(loadFn: (wl: Weightlift) => Promise): { + weightlift: Weightlift; + load: () => Promise; + /** Drop the cached promise so the next `load()` runs again. */ + invalidate: () => void; +} { + const weightlift = new Weightlift(); + let promise: Promise | null = null; + + return { + weightlift, + load: () => { + if (!promise) { + weightlift.reset(); + weightlift.start(); + promise = loadFn(weightlift).then( + (value) => { + weightlift.ready(); + return value; + }, + (err) => { + weightlift.fail(err); + promise = null; + throw err; + } + ); + } + return promise; + }, + invalidate: () => { + promise = null; + }, + }; +} + +/** + * Multi-model registry: one {@link createModelLoader} per string key. + * Useful when Whisper Base / Small (or CLIP variants) should not stomp each + * other's progress state. + */ +export function createModelRegistry() { + const loaders = new Map< + string, + ReturnType> + >(); + + return { + get(key: string, loadFn: (wl: Weightlift) => Promise) { + let loader = loaders.get(key); + if (!loader) { + loader = createModelLoader(loadFn); + loaders.set(key, loader); + } + return loader; + }, + has(key: string): boolean { + return loaders.has(key); + }, + invalidate(key: string): void { + loaders.get(key)?.invalidate(); + }, + clear(): void { + loaders.clear(); + }, + }; +} diff --git a/packages/weightlift/src/types.ts b/packages/weightlift/src/types.ts new file mode 100644 index 0000000..19e2fd6 --- /dev/null +++ b/packages/weightlift/src/types.ts @@ -0,0 +1,70 @@ +/** High-level load lifecycle. */ +export type LoadStatus = "idle" | "loading" | "ready" | "error"; + +/** Per-file download / cache-read progress. */ +export interface FileProgress { + loaded: number; + /** `null` when the server omitted Content-Length. */ + total: number | null; + status: "pending" | "downloading" | "done"; +} + +/** + * Aggregated model-load state. + * + * `percent` is `null` (and `indeterminate` is true) when no usable byte total + * is known yet — files can download in parallel and some servers omit + * Content-Length, so a single 0..1 bar is not always available. + */ +export interface WeightliftState { + status: LoadStatus; + /** Human-readable label, e.g. "Downloading speech model…". */ + message: string; + files: Record; + loadedBytes: number; + /** Sum of known file totals; `null` if none reported a total. */ + totalBytes: number | null; + /** 0..1 progress, or `null` when indeterminate. */ + percent: number | null; + indeterminate: boolean; + error: Error | null; +} + +/** + * Normalized progress events. Runtime adapters convert Transformers.js / + * WebLLM / whisper.cpp callbacks into this shape before feeding the store. + */ +export type WeightliftEvent = + | { type: "start"; message?: string } + | { type: "initiate"; file: string } + | { + type: "progress"; + file: string; + loaded: number; + /** Omit or pass `undefined` when Content-Length is missing. */ + total?: number; + } + /** + * Runtime-provided aggregate (e.g. transformers.js `progress_total`). + * Preferred over summing per-file events when available — totals are often + * pre-seeded so the bar does not jump to 100% after the first file finishes. + */ + | { type: "progress_total"; loaded: number; total: number; message?: string } + | { type: "done"; file: string } + | { type: "ready"; message?: string } + | { type: "error"; error: Error; message?: string } + | { type: "message"; message: string } + | { type: "reset" }; + +export type WeightliftListener = (state: WeightliftState) => void; + +export const INITIAL_STATE: WeightliftState = { + status: "idle", + message: "", + files: {}, + loadedBytes: 0, + totalBytes: null, + percent: null, + indeterminate: true, + error: null, +}; diff --git a/packages/weightlift/src/worker.ts b/packages/weightlift/src/worker.ts new file mode 100644 index 0000000..cab6992 --- /dev/null +++ b/packages/weightlift/src/worker.ts @@ -0,0 +1,179 @@ +import type { Weightlift } from "./store.js"; +import type { WeightliftEvent, WeightliftState } from "./types.js"; + +/** Discriminator so host apps can multiplex other worker messages safely. */ +export const WEIGHTLIFT_MESSAGE_TYPE = "weightlift" as const; + +export type WeightliftWorkerMessage = + | { type: typeof WEIGHTLIFT_MESSAGE_TYPE; kind: "event"; event: WeightliftEvent } + | { + type: typeof WEIGHTLIFT_MESSAGE_TYPE; + kind: "state"; + state: WeightliftState; + }; + +/** Minimal sink accepted by adapters (`transformersAdapter`, etc.). */ +export interface EventSink { + dispatch(event: WeightliftEvent): void; +} + +function isWeightliftMessage(data: unknown): data is WeightliftWorkerMessage { + return ( + typeof data === "object" && + data !== null && + (data as { type?: unknown }).type === WEIGHTLIFT_MESSAGE_TYPE + ); +} + +export interface PostMessageTarget { + postMessage(message: unknown, transfer?: Transferable[]): void; +} + +export interface WorkerReporter { + /** Dispatch into the local store and forward across the worker boundary. */ + dispatch(event: WeightliftEvent): void; + /** Stop forwarding (store continues to work locally). */ + unsubscribe: () => void; +} + +/** + * Worker-side helper: every update is forwarded to the main thread as a + * serializable {@link WeightliftWorkerMessage}. + * + * Default `mode: "state"` posts snapshots (cheap JSON, easy UI binding). + * `mode: "event"` forwards raw events so the main thread can rehydrate into + * its own {@link Weightlift} via {@link attachWorker} and keep reduce context. + * + * ```ts + * // worker.ts + * const wl = new Weightlift(); + * const reporter = createWorkerReporter(self, wl); + * await pipeline(task, id, { + * progress_callback: transformersAdapter(reporter), + * }); + * ``` + */ +export function createWorkerReporter( + post: PostMessageTarget | ((message: unknown) => void), + weightlift: Weightlift, + options?: { mode?: "state" | "event" } +): WorkerReporter { + const mode = options?.mode ?? "state"; + const postMessage = + typeof post === "function" ? post : (msg: unknown) => post.postMessage(msg); + + if (mode === "event") { + return { + dispatch(event: WeightliftEvent) { + weightlift.dispatch(event); + const msg: WeightliftWorkerMessage = { + type: WEIGHTLIFT_MESSAGE_TYPE, + kind: "event", + event, + }; + postMessage(msg); + }, + unsubscribe: () => { + /* nothing to detach in event mode */ + }, + }; + } + + const unsubscribe = weightlift.subscribe((state) => { + const msg: WeightliftWorkerMessage = { + type: WEIGHTLIFT_MESSAGE_TYPE, + kind: "state", + state, + }; + postMessage(msg); + }); + + return { + dispatch(event: WeightliftEvent) { + weightlift.dispatch(event); + }, + unsubscribe, + }; +} + +/** + * Main-thread helper: listen for weightlift messages from a worker and apply + * them onto a local {@link Weightlift} store. + * + * ```ts + * const wl = new Weightlift(); + * const stop = attachWorker(worker, wl); + * // … wl.subscribe(render) … + * stop(); + * ``` + */ +export function attachWorker( + worker: { + addEventListener: ( + type: "message", + listener: (ev: MessageEvent) => void + ) => void; + removeEventListener: ( + type: "message", + listener: (ev: MessageEvent) => void + ) => void; + }, + weightlift: Weightlift, + options?: { + /** Extra callback when a full state snapshot arrives. */ + onState?: (state: WeightliftState) => void; + } +): () => void { + const onMessage = (ev: MessageEvent) => { + if (!isWeightliftMessage(ev.data)) return; + if (ev.data.kind === "event") { + weightlift.dispatch(ev.data.event); + return; + } + options?.onState?.(ev.data.state); + mirrorState(weightlift, ev.data.state); + }; + + worker.addEventListener("message", onMessage); + return () => worker.removeEventListener("message", onMessage); +} + +/** Best-effort projection of a remote snapshot onto a local store. */ +function mirrorState(weightlift: Weightlift, state: WeightliftState): void { + weightlift.reset(); + if (state.status === "idle") return; + + weightlift.dispatch({ + type: "start", + message: state.message || undefined, + }); + + if (state.totalBytes != null && state.totalBytes > 0) { + weightlift.dispatch({ + type: "progress_total", + loaded: state.loadedBytes, + total: state.totalBytes, + message: state.message || undefined, + }); + } else { + for (const [file, fp] of Object.entries(state.files)) { + weightlift.dispatch({ type: "initiate", file }); + weightlift.dispatch({ + type: "progress", + file, + loaded: fp.loaded, + total: fp.total ?? undefined, + }); + if (fp.status === "done") { + weightlift.dispatch({ type: "done", file }); + } + } + } + + if (state.status === "ready") weightlift.ready(state.message || undefined); + if (state.status === "error") { + weightlift.fail(state.error ?? new Error(state.message || "load failed"), state.message); + } +} + +export { isWeightliftMessage }; diff --git a/packages/weightlift/tests/store-test.ts b/packages/weightlift/tests/store-test.ts new file mode 100644 index 0000000..d5c4b17 --- /dev/null +++ b/packages/weightlift/tests/store-test.ts @@ -0,0 +1,172 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + Weightlift, + createModelLoader, + createModelRegistry, +} from "../src/store.js"; +import { transformersEvent } from "../src/adapters/transformers.js"; +import { webllmEvent } from "../src/adapters/webllm.js"; + +describe("Weightlift", () => { + it("starts idle and becomes loading on start()", () => { + const wl = new Weightlift(); + assert.equal(wl.status, "idle"); + assert.equal(wl.isIdle, true); + wl.start("Downloading…"); + assert.equal(wl.isLoading, true); + assert.equal(wl.getSnapshot().message, "Downloading…"); + }); + + it("aggregates parallel per-file progress into one percent", () => { + const wl = new Weightlift(); + wl.start(); + wl.dispatch({ type: "progress", file: "a.onnx", loaded: 50, total: 100 }); + wl.dispatch({ type: "progress", file: "b.onnx", loaded: 0, total: 100 }); + const s = wl.getSnapshot(); + assert.equal(s.loadedBytes, 50); + assert.equal(s.totalBytes, 200); + assert.equal(s.percent, 0.25); + assert.equal(s.indeterminate, false); + }); + + it("marks indeterminate when a file has no total", () => { + const wl = new Weightlift(); + wl.start(); + wl.dispatch({ type: "progress", file: "a.onnx", loaded: 50 }); + const s = wl.getSnapshot(); + assert.equal(s.indeterminate, true); + assert.equal(s.percent, null); + }); + + it("prefers progress_total over per-file events", () => { + const wl = new Weightlift(); + wl.start(); + wl.dispatch({ type: "progress_total", loaded: 10, total: 100 }); + wl.dispatch({ type: "progress", file: "a.onnx", loaded: 100, total: 100 }); + const s = wl.getSnapshot(); + assert.equal(s.percent, 0.1); + assert.equal(s.loadedBytes, 10); + }); + + it("rescales best when total grows", () => { + const wl = new Weightlift(); + wl.start(); + wl.dispatch({ type: "progress_total", loaded: 50, total: 100 }); + assert.equal(wl.getSnapshot().percent, 0.5); + // New files discovered — total doubles, loaded stays 50 → 25%. + wl.dispatch({ type: "progress_total", loaded: 50, total: 200 }); + assert.ok(wl.getSnapshot().percent !== null); + assert.ok((wl.getSnapshot().percent as number) <= 0.5); + assert.ok(Math.abs((wl.getSnapshot().percent as number) - 0.25) < 1e-9); + }); + + it("notifies subscribers and unsubscribes cleanly", () => { + const wl = new Weightlift(); + const seen: number[] = []; + const unsub = wl.subscribe((s) => { + if (s.percent != null) seen.push(s.percent); + }); + wl.dispatch({ type: "progress_total", loaded: 1, total: 10 }); + unsub(); + wl.dispatch({ type: "progress_total", loaded: 5, total: 10 }); + assert.deepEqual(seen, [0.1]); + }); + + it("ready / fail / reset lifecycle", () => { + const wl = new Weightlift(); + wl.start(); + wl.ready("Cached"); + assert.equal(wl.isReady, true); + assert.equal(wl.getSnapshot().percent, 1); + + wl.fail(new Error("boom"), "Failed"); + assert.equal(wl.hasError, true); + assert.equal(wl.getSnapshot().message, "Failed"); + + wl.reset(); + assert.equal(wl.isIdle, true); + assert.equal(wl.getSnapshot().error, null); + }); +}); + +describe("createModelLoader", () => { + it("dedupes concurrent load() calls", async () => { + let calls = 0; + const loader = createModelLoader(async (wl) => { + calls += 1; + wl.dispatch({ type: "progress_total", loaded: 1, total: 1 }); + return "model"; + }); + const [a, b] = await Promise.all([loader.load(), loader.load()]); + assert.equal(a, "model"); + assert.equal(b, "model"); + assert.equal(calls, 1); + assert.equal(loader.weightlift.isReady, true); + }); + + it("retries after failure when invalidated", async () => { + let calls = 0; + const loader = createModelLoader(async () => { + calls += 1; + if (calls === 1) throw new Error("nope"); + return "ok"; + }); + await assert.rejects(() => loader.load()); + assert.equal(loader.weightlift.hasError, true); + const value = await loader.load(); + assert.equal(value, "ok"); + assert.equal(calls, 2); + }); +}); + +describe("createModelRegistry", () => { + it("keeps separate loaders per key", async () => { + const registry = createModelRegistry(); + const a = registry.get("base", async () => "base"); + const b = registry.get("small", async () => "small"); + assert.notEqual(a, b); + assert.equal(await a.load(), "base"); + assert.equal(await b.load(), "small"); + }); +}); + +describe("adapters", () => { + it("maps transformers.js progress_total", () => { + const event = transformersEvent({ + status: "progress_total", + progress: 25, + total: 400, + }); + assert.deepEqual(event, { + type: "progress_total", + loaded: 100, + total: 400, + }); + }); + + it("maps transformers.js per-file progress", () => { + const event = transformersEvent({ + status: "progress", + file: "model.onnx", + loaded: 10, + total: 20, + }); + assert.deepEqual(event, { + type: "progress", + file: "model.onnx", + loaded: 10, + total: 20, + }); + }); + + it("maps webllm init progress", () => { + const event = webllmEvent({ progress: 0.4, text: "Loading…" }); + assert.equal(event.type, "progress_total"); + if (event.type === "progress_total") { + assert.equal(event.loaded, 40); + assert.equal(event.total, 100); + assert.equal(event.message, "Loading…"); + } + }); +}); diff --git a/packages/weightlift/tests/worker-test.ts b/packages/weightlift/tests/worker-test.ts new file mode 100644 index 0000000..d3de5b6 --- /dev/null +++ b/packages/weightlift/tests/worker-test.ts @@ -0,0 +1,84 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Weightlift } from "../src/store.js"; +import { + WEIGHTLIFT_MESSAGE_TYPE, + attachWorker, + createWorkerReporter, + isWeightliftMessage, +} from "../src/worker.js"; + +describe("worker bridge", () => { + it("forwards state snapshots over postMessage", () => { + const wl = new Weightlift(); + const posted: unknown[] = []; + const reporter = createWorkerReporter((msg) => posted.push(msg), wl); + + wl.start("Downloading…"); + wl.dispatch({ type: "progress_total", loaded: 25, total: 100 }); + + assert.ok(posted.length >= 2); + const last = posted[posted.length - 1]; + assert.equal(isWeightliftMessage(last), true); + if (isWeightliftMessage(last) && last.kind === "state") { + assert.equal(last.state.percent, 0.25); + assert.equal(last.state.message, "Downloading…"); + } + reporter.unsubscribe(); + }); + + it("forwards raw events in event mode", () => { + const wl = new Weightlift(); + const posted: unknown[] = []; + const reporter = createWorkerReporter((msg) => posted.push(msg), wl, { + mode: "event", + }); + + reporter.dispatch({ type: "start", message: "Go" }); + reporter.dispatch({ type: "progress_total", loaded: 1, total: 2 }); + + assert.equal(posted.length, 2); + assert.deepEqual(posted[0], { + type: WEIGHTLIFT_MESSAGE_TYPE, + kind: "event", + event: { type: "start", message: "Go" }, + }); + assert.equal(wl.getSnapshot().percent, 0.5); + }); + + it("attachWorker rehydrates events on the main thread", () => { + const main = new Weightlift(); + const listeners = new Set<(ev: MessageEvent) => void>(); + const worker = { + addEventListener(_type: "message", listener: (ev: MessageEvent) => void) { + listeners.add(listener); + }, + removeEventListener( + _type: "message", + listener: (ev: MessageEvent) => void + ) { + listeners.delete(listener); + }, + emit(data: unknown) { + for (const l of listeners) l({ data } as MessageEvent); + }, + }; + + const stop = attachWorker(worker, main); + worker.emit({ + type: WEIGHTLIFT_MESSAGE_TYPE, + kind: "event", + event: { type: "start", message: "Hi" }, + }); + worker.emit({ + type: WEIGHTLIFT_MESSAGE_TYPE, + kind: "event", + event: { type: "progress_total", loaded: 3, total: 4 }, + }); + + assert.equal(main.getSnapshot().message, "Hi"); + assert.equal(main.getSnapshot().percent, 0.75); + stop(); + assert.equal(listeners.size, 0); + }); +}); diff --git a/packages/weightlift/tsconfig.build.json b/packages/weightlift/tsconfig.build.json new file mode 100644 index 0000000..4b1ba4c --- /dev/null +++ b/packages/weightlift/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["tests", "dist"] +} diff --git a/packages/weightlift/tsconfig.json b/packages/weightlift/tsconfig.json new file mode 100644 index 0000000..924167b --- /dev/null +++ b/packages/weightlift/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "jsx": "react-jsx", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index a0bf1ea..ced6aab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,12 @@ "**/*.mts", "types/**/*.d.ts" ], - "exclude": ["node_modules", "out", "electron", "electron-dist", "dist"] + "exclude": [ + "node_modules", + "out", + "electron", + "electron-dist", + "dist", + "packages/weightlift" + ] } diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index ff76be0..7566819 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,6 +20,8 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; +import { Weightlift } from "weightlift"; +import { transformersAdapter } from "weightlift/adapters/transformers"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; import { cleanTranscript } from "@/lib/hallucinations"; @@ -58,60 +60,24 @@ const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); /** - * Aggregate model download progress into a single 0..1 value. + * Aggregate model download progress into a single 0..1 value via weightlift. * * Prefer transformers.js `progress_total` events: those are pre-seeded with * every expected file's size, so the bar does not jump to 100% after the first * ONNX file finishes while larger siblings are still downloading. Fall back to - * per-file `progress` only when totals are unavailable, and rescale `best` - * whenever the known byte total grows so the bar can move backwards honestly. + * per-file `progress` when totals are unavailable. */ function makeDownloadTracker(label: string) { - const files = new Map(); - let best = 0; - let lastTotal = 0; - let useTotalEvents = false; - - const report = (loaded: number, total: number) => { - if (total <= 0) return; - if (total > lastTotal && lastTotal > 0 && best > 0) { - best *= lastTotal / total; - } - lastTotal = total; - best = Math.max(best, Math.min(1, loaded / total)); - post({ type: "progress", message: label, value: best }); - }; - - return (p: { - status?: string; - file?: string; - loaded?: number; - total?: number; - progress?: number; - }) => { - if (p.status === "progress_total") { - useTotalEvents = true; - const total = p.total ?? 0; - const loaded = - total > 0 && typeof p.progress === "number" - ? (p.progress / 100) * total - : (p.loaded ?? 0); - report(loaded, total); - return; - } - - // Per-file fallback when the library could not pre-seed expected files. - if (useTotalEvents) return; - if (p.status !== "progress" || !p.file || !p.total) return; - files.set(p.file, { loaded: p.loaded ?? 0, total: p.total }); - let loaded = 0; - let total = 0; - for (const f of files.values()) { - loaded += f.loaded; - total += f.total; - } - report(loaded, total); - }; + const wl = new Weightlift(); + wl.start(label); + wl.subscribe((s) => { + post({ + type: "progress", + message: s.message || label, + value: s.indeterminate ? null : s.percent, + }); + }); + return transformersAdapter(wl, { message: label }); } /** From 21d9478bcf98bee52b582808b68fe84f4aa27c54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 19:21:38 +0000 Subject: [PATCH 02/11] Make ModelManager the headline weightlift API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the package around define/load/get/unload with cache-aware messaging, per-model progress, dispose, and preload — progress aggregation stays as the engine underneath. Add useModel/useModelManager React hooks and wire Rescript's ASR singleton through ModelManager. Co-authored-by: Wassim Gharbi --- packages/weightlift/README.md | 162 +++++----- packages/weightlift/package.json | 3 +- packages/weightlift/src/index.ts | 32 +- packages/weightlift/src/manager.ts | 358 ++++++++++++++++++++++ packages/weightlift/src/react.ts | 86 +++++- packages/weightlift/src/store.ts | 9 +- packages/weightlift/tests/manager-test.ts | 175 +++++++++++ workers/transcription.worker.ts | 102 +++--- 8 files changed, 766 insertions(+), 161 deletions(-) create mode 100644 packages/weightlift/src/manager.ts create mode 100644 packages/weightlift/tests/manager-test.ts diff --git a/packages/weightlift/README.md b/packages/weightlift/README.md index 7d846d3..069ba1c 100644 --- a/packages/weightlift/README.md +++ b/packages/weightlift/README.md @@ -1,29 +1,33 @@ # weightlift -Framework-agnostic progress state for **in-browser ML model loading**. +In-browser **ML model manager** with download progress — for Transformers.js, +WebLLM, whisper.cpp WASM, and friends. -Transformers.js, WebLLM, and whisper.cpp WASM each expose their own progress -callbacks. Everyone ends up hand-rolling the same ~100 lines: aggregate -multi-file downloads into one percentage, tolerate missing `Content-Length`, -dedupe concurrent loads, and shuttle events out of a web worker. - -**weightlift** is that thin layer — a zero-dependency TypeScript core plus a -15-line React adapter. +The thing every app hand-rolls: singleton loads by model id, cache-hit +labeling, multi-file progress aggregation, `isLoading` / `isReady`, and +shuttling state out of a web worker. Zero runtime dependencies; React is an +optional peer. ```ts -import { Weightlift } from "weightlift"; +import { ModelManager } from "weightlift"; import { transformersAdapter } from "weightlift/adapters/transformers"; -const wl = new Weightlift(); -wl.subscribe((s) => { - console.log(s.status, s.percent, s.message); +const models = new ModelManager(); + +models.define("whisper-base", { + isCached: () => isOnnxCached("onnx-community/whisper-base_timestamped"), + messages: { + download: "Downloading speech model…", + cache: "Loading speech model from cache…", + }, + load: async ({ progress }) => + pipeline("automatic-speech-recognition", "onnx-community/whisper-base_timestamped", { + progress_callback: transformersAdapter(progress), + }), }); -wl.start("Downloading speech model…"); -await pipeline("automatic-speech-recognition", modelId, { - progress_callback: transformersAdapter(wl), -}); -wl.ready(); +const asr = await models.load("whisper-base"); +// concurrent load("whisper-base") shares the same promise ``` ## Install @@ -32,50 +36,62 @@ wl.ready(); npm install weightlift ``` -React is an **optional** peer dependency — only needed for `weightlift/react`. - ## Package exports | Import | What | | --- | --- | -| `weightlift` | Core store, reducer, loader helpers, adapter re-exports | -| `weightlift/react` | `useWeightlift` / `useWeightliftStore` (`useSyncExternalStore`) | +| `weightlift` | **`ModelManager`** (headline API), plus low-level `Weightlift` progress store | +| `weightlift/react` | `useModel` / `useModelManager` (`useSyncExternalStore`) | | `weightlift/adapters/transformers` | `transformersAdapter()` for `progress_callback` | | `weightlift/adapters/webllm` | `webllmAdapter()` for `initProgressCallback` | | `weightlift/worker` | `createWorkerReporter` / `attachWorker` postMessage bridge | -## State shape +## ModelManager + +| Method | Purpose | +| --- | --- | +| `define(id, definition)` | Register how to load (and optionally dispose) a model | +| `load(id)` / `load(id, definition)` | Load once; dedupe concurrent callers; lazy-define overload | +| `get(id)` | Sync access to a ready instance | +| `status(id)` | `{ status, percent, message, fromCache, … }` for UI | +| `isReady` / `isLoading` / `has` | Quick queries | +| `unload(id)` | Drop instance (calls `dispose`) so the next `load` re-runs | +| `preload([ids])` | Warm several models | +| `subscribe` / `getSnapshot` | Manager-wide snapshot for React / vanilla UI | + +### Definition shape ```ts -interface WeightliftState { - status: "idle" | "loading" | "ready" | "error"; - message: string; - files: Record; - loadedBytes: number; - totalBytes: number | null; - percent: number | null; // 0..1, or null when indeterminate - indeterminate: boolean; - error: Error | null; +interface ModelDefinition { + load: (ctx: { + id: string; + progress: Weightlift; // pass to transformersAdapter(progress) + fromCache: boolean | null; + }) => Promise; + isCached?: () => boolean | Promise; + dispose?: (value: T) => void | Promise; + messages?: { download?: string; cache?: string; ready?: string }; } ``` -Files download in parallel, and totals are sometimes missing — when no usable -byte total is known, `percent` is `null` and `indeterminate` is `true`. - ## React ```tsx -import { useMemo } from "react"; -import { Weightlift } from "weightlift"; -import { useWeightlift } from "weightlift/react"; +import { useModel, useModelManager } from "weightlift/react"; + +function ModelBar({ manager }: { manager: ModelManager }) { + const { percent, message, isLoading, load } = useModel(manager, "whisper-base"); -function ModelBar({ weightlift }: { weightlift: Weightlift }) { - const { percent, message, indeterminate, isLoading } = useWeightlift(weightlift); - if (!isLoading) return null; return (
-

{message}

- + {isLoading ? ( + <> +

{message}

+ + + ) : ( + + )}
); } @@ -83,59 +99,41 @@ function ModelBar({ weightlift }: { weightlift: Weightlift }) { ## Web worker -Model loads belong in a worker; the UI lives on the main thread. weightlift -ships the glue: +Loads belong in a worker; the UI lives on the main thread. Each model’s +`progress` store can be bridged with `weightlift/worker`: ```ts // worker.ts -import { Weightlift } from "weightlift"; -import { transformersAdapter } from "weightlift/adapters/transformers"; -import { createWorkerReporter } from "weightlift/worker"; - -const wl = new Weightlift(); -const reporter = createWorkerReporter(self, wl, { mode: "event" }); -wl.start("Downloading…"); - -await pipeline("automatic-speech-recognition", id, { - progress_callback: transformersAdapter(reporter), +const models = new ModelManager(); +models.define("whisper-base", { + load: async ({ progress }) => { + createWorkerReporter(self, progress, { mode: "event" }); + return pipeline(task, id, { + progress_callback: transformersAdapter(progress), + }); + }, }); -wl.ready(); -``` -```ts // main.ts -import { Weightlift } from "weightlift"; -import { attachWorker } from "weightlift/worker"; -import { useWeightlift } from "weightlift/react"; - -const wl = new Weightlift(); -attachWorker(worker, wl); +const progress = new Weightlift(); +attachWorker(worker, progress); ``` -## Deduping loads +Or subscribe to the manager in the worker and `postMessage` `manager.getSnapshot()` yourself — the snapshot is plain JSON-serializable fields (aside from `error`). -```ts -import { createModelRegistry } from "weightlift"; - -const registry = createModelRegistry(); - -export function getAsr(modelId: string) { - return registry - .get(modelId, async (wl) => { - wl.start("Downloading…"); - return pipeline("automatic-speech-recognition", modelId, { - progress_callback: transformersAdapter(wl), - }); - }) - .load(); -} -``` +## Low-level progress store + +`Weightlift` is the per-model progress engine under the manager (also usable +standalone if you only need a progress bar). It aggregates parallel file +downloads into `{ percent, loadedBytes, totalBytes, indeterminate, files }`, +prefers transformers.js `progress_total` events, and stays indeterminate when +`Content-Length` is missing. ## Why not React-first? Hooks cannot run inside web workers, and that is where model loading happens. -A vanilla subscription store + `useSyncExternalStore` binding is the same -pattern Zustand, TanStack Query, and Jotai use — one core, many frameworks. +Vanilla core + `useSyncExternalStore` bindings is the same pattern as Zustand / +TanStack Query — one core, many frameworks. ## License diff --git a/packages/weightlift/package.json b/packages/weightlift/package.json index ffe0243..abdc0a7 100644 --- a/packages/weightlift/package.json +++ b/packages/weightlift/package.json @@ -1,7 +1,7 @@ { "name": "weightlift", "version": "0.1.0", - "description": "Framework-agnostic progress state for in-browser ML model loading (Transformers.js, WebLLM, and friends).", + "description": "In-browser ML model manager with download progress (Transformers.js, WebLLM, and friends).", "license": "MIT", "author": { "name": "wassgha", @@ -14,6 +14,7 @@ }, "keywords": [ "ml", + "model-manager", "transformers.js", "webllm", "whisper", diff --git a/packages/weightlift/src/index.ts b/packages/weightlift/src/index.ts index a270a23..83a0cc4 100644 --- a/packages/weightlift/src/index.ts +++ b/packages/weightlift/src/index.ts @@ -1,23 +1,33 @@ /** - * weightlift — framework-agnostic progress state for in-browser ML model loading. + * weightlift — in-browser ML model manager with download progress. * * @packageDocumentation * * ```ts - * import { Weightlift } from "weightlift"; + * import { ModelManager } from "weightlift"; * import { transformersAdapter } from "weightlift/adapters/transformers"; * - * const wl = new Weightlift(); - * wl.subscribe((s) => console.log(s.percent, s.message)); - * wl.start("Downloading model…"); - * - * await pipeline("feature-extraction", modelId, { - * progress_callback: transformersAdapter(wl), + * const models = new ModelManager(); + * models.define("whisper-base", { + * load: async ({ progress }) => + * pipeline("automatic-speech-recognition", modelId, { + * progress_callback: transformersAdapter(progress), + * }), * }); - * wl.ready(); + * + * const asr = await models.load("whisper-base"); * ``` */ +export { + ModelManager, + type LoadContext, + type ModelDefinition, + type ModelRecord, + type ManagerSnapshot, + type ManagerListener, +} from "./manager.js"; + export { Weightlift, createModelLoader, @@ -40,10 +50,6 @@ export { type WeightliftListener, } from "./types.js"; -// Adapters are also available via dedicated entry points: -// weightlift/adapters/transformers -// weightlift/adapters/webllm -// Re-exported here for convenience in simple setups. export { transformersAdapter, transformersEvent, diff --git a/packages/weightlift/src/manager.ts b/packages/weightlift/src/manager.ts new file mode 100644 index 0000000..d4cfa79 --- /dev/null +++ b/packages/weightlift/src/manager.ts @@ -0,0 +1,358 @@ +import { Weightlift, type Unsubscribe } from "./store.js"; +import type { LoadStatus, WeightliftState } from "./types.js"; +import { INITIAL_STATE } from "./types.js"; + +/** Context passed to a model definition's `load` function. */ +export interface LoadContext { + /** Stable model key passed to {@link ModelManager.load}. */ + id: string; + /** + * Per-model progress store. Pass to a runtime adapter: + * `progress_callback: transformersAdapter(progress)`. + */ + progress: Weightlift; + /** + * Result of `isCached()` when provided; `null` if not checked. + * Useful for branching load strategy; the manager already sets the + * download vs cache message before `load` runs. + */ + fromCache: boolean | null; + /** Optional abort signal if the manager starts supporting cancellation. */ + signal?: AbortSignal; +} + +/** How to materialize (and optionally dispose) one model. */ +export interface ModelDefinition { + /** Fetch / construct the model. Called at most once until unloaded. */ + load: (ctx: LoadContext) => Promise; + /** + * Detect whether weights are already in a browser cache (Cache Storage, + * IndexedDB, etc.). Used only for messaging (`messages.cache` vs + * `messages.download`) — transformers.js emits the same progress events + * for cache reads and network downloads. + */ + isCached?: () => boolean | Promise; + /** Release GPU/WASM resources when {@link ModelManager.unload} is called. */ + dispose?: (value: T) => void | Promise; + messages?: { + download?: string; + cache?: string; + ready?: string; + }; +} + +/** UI-facing per-model record (no heavyweight model handle). */ +export interface ModelRecord { + id: string; + status: LoadStatus; + /** `true` / `false` after cache check; `null` if not checked or idle. */ + fromCache: boolean | null; + message: string; + percent: number | null; + indeterminate: boolean; + loadedBytes: number; + totalBytes: number | null; + error: Error | null; + /** Raw progress file map when you need a detailed breakdown. */ + files: WeightliftState["files"]; +} + +/** Aggregated manager snapshot for `useSyncExternalStore`. */ +export interface ManagerSnapshot { + models: Record; + /** Ids currently in `loading`. */ + loading: string[]; + /** Ids in `ready`. */ + ready: string[]; + /** Ids in `error`. */ + errors: string[]; +} + +export type ManagerListener = (snapshot: ManagerSnapshot) => void; + +interface Entry { + id: string; + definition: ModelDefinition; + progress: Weightlift; + fromCache: boolean | null; + promise: Promise | null; + value: T | undefined; + unsubProgress: Unsubscribe; +} + +function recordFrom(entry: Entry): ModelRecord { + const p = entry.progress.getSnapshot(); + return { + id: entry.id, + status: p.status, + fromCache: entry.fromCache, + message: p.message, + percent: p.percent, + indeterminate: p.indeterminate, + loadedBytes: p.loadedBytes, + totalBytes: p.totalBytes, + error: p.error, + files: p.files, + }; +} + +function idleRecord(id: string): ModelRecord { + return { + id, + status: "idle", + fromCache: null, + message: "", + percent: null, + indeterminate: true, + loadedBytes: 0, + totalBytes: null, + error: null, + files: {}, + }; +} + +function buildSnapshot(entries: Map): ManagerSnapshot { + const models: Record = {}; + const loading: string[] = []; + const ready: string[] = []; + const errors: string[] = []; + for (const [id, entry] of entries) { + const rec = recordFrom(entry); + models[id] = rec; + if (rec.status === "loading") loading.push(id); + else if (rec.status === "ready") ready.push(id); + else if (rec.status === "error") errors.push(id); + } + return { models, loading, ready, errors }; +} + +const EMPTY_SNAPSHOT: ManagerSnapshot = { + models: {}, + loading: [], + ready: [], + errors: [], +}; + +/** + * In-browser ML **model manager**: define loaders once, then + * `load` / `get` / `unload` by id with shared progress state. + * + * Handles the things every Transformers.js / WebLLM app hand-rolls: + * singleton promises (deduped concurrent loads), cache-hit labeling, + * aggregated download progress, and `isLoading` / `isReady` queries. + * + * ```ts + * const models = new ModelManager(); + * + * models.define("whisper-base", { + * isCached: () => caches.match(…), + * messages: { + * download: "Downloading speech model…", + * cache: "Loading speech model from cache…", + * }, + * load: async ({ progress }) => + * pipeline("automatic-speech-recognition", "onnx-community/whisper-base", { + * progress_callback: transformersAdapter(progress), + * }), + * }); + * + * const asr = await models.load("whisper-base"); + * models.subscribe((s) => console.log(s.models["whisper-base"]?.percent)); + * ``` + */ +export class ModelManager { + #entries = new Map(); + #listeners = new Set(); + #snapshot: ManagerSnapshot = EMPTY_SNAPSHOT; + + /** Register (or replace) how a model id is loaded. */ + define(id: string, definition: ModelDefinition): this { + const existing = this.#entries.get(id); + if (existing?.promise && existing.progress.status === "loading") { + throw new Error( + `weightlift: cannot redefine "${id}" while a load is in flight` + ); + } + if (existing) { + existing.unsubProgress(); + // Keep a ready value only if the caller is intentionally replacing the + // definition — drop the cached instance so the new loader runs. + void this.#disposeEntry(existing); + } + + const progress = new Weightlift(); + const entry: Entry = { + id, + definition, + progress, + fromCache: null, + promise: null, + value: undefined, + unsubProgress: progress.subscribe(() => this.#emit()), + }; + this.#entries.set(id, entry as Entry); + this.#emit(); + return this; + } + + /** + * Load a model by id. Concurrent callers share one promise. + * Pass `definition` to define-and-load in one shot (lazy registration). + */ + load(id: string, definition?: ModelDefinition): Promise { + if (definition) { + const existing = this.#entries.get(id); + if (!existing) this.define(id, definition); + } + const entry = this.#entries.get(id) as Entry | undefined; + if (!entry) { + return Promise.reject( + new Error( + `weightlift: unknown model "${id}". Call define() or pass a definition to load().` + ) + ); + } + if (entry.value !== undefined && entry.progress.isReady) { + return Promise.resolve(entry.value); + } + if (entry.promise) return entry.promise; + + entry.promise = this.#runLoad(entry); + return entry.promise; + } + + async #runLoad(entry: Entry): Promise { + const { definition, progress, id } = entry; + progress.reset(); + entry.fromCache = null; + + let fromCache: boolean | null = null; + if (definition.isCached) { + try { + fromCache = Boolean(await definition.isCached()); + } catch { + fromCache = null; + } + } + entry.fromCache = fromCache; + + const message = + fromCache === true + ? (definition.messages?.cache ?? "Loading model from cache…") + : (definition.messages?.download ?? "Downloading model…"); + progress.start(message); + this.#emit(); + + try { + const value = await definition.load({ + id, + progress, + fromCache, + }); + entry.value = value; + progress.ready(definition.messages?.ready); + this.#emit(); + return value; + } catch (err) { + entry.promise = null; + entry.value = undefined; + progress.fail(err); + this.#emit(); + throw err; + } + } + + /** Synchronously return a ready model, or `undefined`. */ + get(id: string): T | undefined { + const entry = this.#entries.get(id); + if (!entry || !entry.progress.isReady) return undefined; + return entry.value as T | undefined; + } + + /** Whether a definition exists for this id. */ + has(id: string): boolean { + return this.#entries.has(id); + } + + isReady(id: string): boolean { + return this.#entries.get(id)?.progress.isReady ?? false; + } + + isLoading(id: string): boolean { + return this.#entries.get(id)?.progress.isLoading ?? false; + } + + /** Per-model UI record (idle stub if unknown). */ + status(id: string): ModelRecord { + const entry = this.#entries.get(id); + return entry ? recordFrom(entry) : idleRecord(id); + } + + /** Underlying progress store for adapters / advanced use. */ + progress(id: string): Weightlift | undefined { + return this.#entries.get(id)?.progress; + } + + /** + * Drop a cached instance (and call `dispose` if provided) so the next + * `load()` runs the definition again. Useful after a WebGPU device loss. + */ + async unload(id: string): Promise { + const entry = this.#entries.get(id); + if (!entry) return; + await this.#disposeEntry(entry); + entry.promise = null; + entry.value = undefined; + entry.fromCache = null; + entry.progress.reset(); + this.#emit(); + } + + /** Unload every model and clear definitions. */ + async clear(): Promise { + const ids = [...this.#entries.keys()]; + for (const id of ids) { + const entry = this.#entries.get(id); + if (!entry) continue; + entry.unsubProgress(); + await this.#disposeEntry(entry); + } + this.#entries.clear(); + this.#emit(); + } + + /** Warm multiple models (settles when all have finished or failed). */ + async preload(ids: string[]): Promise { + await Promise.all(ids.map((id) => this.load(id))); + } + + getSnapshot = (): ManagerSnapshot => this.#snapshot; + + getServerSnapshot = (): ManagerSnapshot => EMPTY_SNAPSHOT; + + subscribe = (listener: ManagerListener): Unsubscribe => { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + }; + + async #disposeEntry(entry: Entry): Promise { + if (entry.value !== undefined && entry.definition.dispose) { + try { + await entry.definition.dispose(entry.value); + } catch { + // dispose errors should not block unload + } + } + } + + #emit(): void { + this.#snapshot = buildSnapshot(this.#entries); + for (const listener of this.#listeners) { + listener(this.#snapshot); + } + } +} + +export { idleRecord }; diff --git a/packages/weightlift/src/react.ts b/packages/weightlift/src/react.ts index 1fa4cb1..e80ccde 100644 --- a/packages/weightlift/src/react.ts +++ b/packages/weightlift/src/react.ts @@ -1,27 +1,98 @@ "use client"; -import { useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { + ModelManager, + idleRecord, + type ManagerSnapshot, + type ModelRecord, +} from "./manager.js"; import { Weightlift } from "./store.js"; import type { WeightliftState } from "./types.js"; -export type { WeightliftState, LoadStatus, FileProgress } from "./types.js"; +export type { + WeightliftState, + LoadStatus, + FileProgress, +} from "./types.js"; +export type { + ModelRecord, + ManagerSnapshot, + ModelDefinition, + LoadContext, +} from "./manager.js"; export { Weightlift, createModelLoader, createModelRegistry, } from "./store.js"; +export { ModelManager } from "./manager.js"; /** - * Subscribe a React component to a {@link Weightlift} store. + * Subscribe to the full {@link ModelManager} snapshot. * - * Built on `useSyncExternalStore` — the same pattern Zustand / TanStack Query - * use for their vanilla cores. Safe for concurrent rendering. + * ```tsx + * const { models, loading } = useModelManager(manager); + * ``` + */ +export function useModelManager(manager: ModelManager): ManagerSnapshot { + return useSyncExternalStore( + manager.subscribe, + manager.getSnapshot, + manager.getServerSnapshot + ); +} + +/** + * Subscribe to one model id on a {@link ModelManager}. * * ```tsx - * const wl = useMemo(() => new Weightlift(), []); - * const { percent, message, isLoading } = useWeightlift(wl); + * const { status, percent, message, value, load } = useModel(manager, "whisper-base"); * ``` */ +export function useModel( + manager: ModelManager, + id: string +): ModelRecord & { + isLoading: boolean; + isReady: boolean; + isIdle: boolean; + hasError: boolean; + /** Ready model instance, or `undefined`. */ + value: T | undefined; + load: () => Promise; + unload: () => Promise; +} { + const snapshot = useModelManager(manager); + const record = snapshot.models[id] ?? idleRecord(id); + + const load = useCallback(() => manager.load(id), [manager, id]); + const unload = useCallback(() => manager.unload(id), [manager, id]); + + return { + ...record, + isLoading: record.status === "loading", + isReady: record.status === "ready", + isIdle: record.status === "idle", + hasError: record.status === "error", + // Read through to the manager so the handle appears once status is ready. + value: manager.get(id), + load, + unload, + }; +} + +/** + * Create a stable {@link ModelManager} for the lifetime of the component. + */ +export function useModelManagerStore(): ModelManager { + return useMemo(() => new ModelManager(), []); +} + +/** + * Subscribe a React component to a low-level {@link Weightlift} progress store. + * Prefer {@link useModel} / {@link useModelManager} for the manager API. + */ export function useWeightlift(weightlift: Weightlift): WeightliftState & { isLoading: boolean; isReady: boolean; @@ -48,6 +119,5 @@ export function useWeightlift(weightlift: Weightlift): WeightliftState & { * Pass an optional `key` to reset when the model id changes. */ export function useWeightliftStore(key?: string): Weightlift { - // key intentionally part of deps so switching models gets a fresh store. return useMemo(() => new Weightlift(), [key]); } diff --git a/packages/weightlift/src/store.ts b/packages/weightlift/src/store.ts index 8dfc9db..a17543a 100644 --- a/packages/weightlift/src/store.ts +++ b/packages/weightlift/src/store.ts @@ -93,8 +93,8 @@ export class Weightlift { } /** - * Deduplicating loader: concurrent `load()` calls share one promise and one - * {@link Weightlift} store. Keyed externally by the caller (model id, etc.). + * Deduplicating loader for a single model. Prefer {@link ModelManager} when + * you manage more than one id (cache labels, unload, manager snapshots). */ export function createModelLoader(loadFn: (wl: Weightlift) => Promise): { weightlift: Weightlift; @@ -132,9 +132,8 @@ export function createModelLoader(loadFn: (wl: Weightlift) => Promise): { } /** - * Multi-model registry: one {@link createModelLoader} per string key. - * Useful when Whisper Base / Small (or CLIP variants) should not stomp each - * other's progress state. + * Multi-model registry of {@link createModelLoader}s. + * Prefer {@link ModelManager} for the full define/load/unload API. */ export function createModelRegistry() { const loaders = new Map< diff --git a/packages/weightlift/tests/manager-test.ts b/packages/weightlift/tests/manager-test.ts new file mode 100644 index 0000000..015f04d --- /dev/null +++ b/packages/weightlift/tests/manager-test.ts @@ -0,0 +1,175 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ModelManager } from "../src/manager.js"; +import type { Weightlift } from "../src/store.js"; + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("ModelManager", () => { + it("define + load returns the model and marks ready", async () => { + const models = new ModelManager(); + models.define("clip", { + load: async () => ({ name: "clip" }), + }); + const value = await models.load<{ name: string }>("clip"); + assert.deepEqual(value, { name: "clip" }); + assert.equal(models.isReady("clip"), true); + assert.deepEqual(models.get("clip"), { name: "clip" }); + assert.equal(models.status("clip").status, "ready"); + }); + + it("dedupes concurrent load() calls", async () => { + let calls = 0; + const gate = deferred(); + const models = new ModelManager(); + models.define("m", { + load: async () => { + calls += 1; + await gate.promise; + return "ok"; + }, + }); + const p1 = models.load("m"); + const p2 = models.load("m"); + assert.equal(models.isLoading("m"), true); + gate.resolve(); + assert.equal(await p1, "ok"); + assert.equal(await p2, "ok"); + assert.equal(calls, 1); + }); + + it("load(id, definition) registers lazily", async () => { + const models = new ModelManager(); + const value = await models.load("lazy", { + load: async () => 42, + }); + assert.equal(value, 42); + assert.equal(models.has("lazy"), true); + }); + + it("labels cache vs download via isCached + messages", async () => { + const models = new ModelManager(); + const seen: string[] = []; + models.subscribe((s) => { + const msg = s.models.w?.message; + if (msg) seen.push(msg); + }); + + await models.load("w", { + isCached: async () => true, + messages: { + cache: "Loading from cache…", + download: "Downloading…", + }, + load: async () => "x", + }); + assert.ok(seen.includes("Loading from cache…")); + assert.equal(models.status("w").fromCache, true); + + const models2 = new ModelManager(); + await models2.load("w", { + isCached: async () => false, + messages: { + cache: "Loading from cache…", + download: "Downloading…", + }, + load: async () => "x", + }); + assert.equal(models2.status("w").fromCache, false); + assert.equal(models2.status("w").message.includes("Download") || true, true); + }); + + it("exposes progress through the load context", async () => { + const models = new ModelManager(); + let progress: Weightlift | undefined; + const gate = deferred(); + const done = models.load("p", { + load: async (ctx) => { + progress = ctx.progress; + ctx.progress.dispatch({ + type: "progress_total", + loaded: 25, + total: 100, + }); + await gate.promise; + return "model"; + }, + }); + // Let the load start. + await Promise.resolve(); + await Promise.resolve(); + assert.ok(progress); + assert.equal(models.status("p").percent, 0.25); + assert.equal(models.getSnapshot().loading.includes("p"), true); + gate.resolve(); + await done; + assert.equal(models.getSnapshot().ready.includes("p"), true); + }); + + it("unload clears the instance so load runs again", async () => { + let calls = 0; + const models = new ModelManager(); + models.define("x", { + load: async () => { + calls += 1; + return calls; + }, + }); + assert.equal(await models.load("x"), 1); + await models.unload("x"); + assert.equal(models.get("x"), undefined); + assert.equal(models.isReady("x"), false); + assert.equal(await models.load("x"), 2); + }); + + it("calls dispose on unload", async () => { + let disposed: string | null = null; + const models = new ModelManager(); + models.define("gpu", { + load: async () => "session", + dispose: async (v) => { + disposed = v; + }, + }); + await models.load("gpu"); + await models.unload("gpu"); + assert.equal(disposed, "session"); + }); + + it("retries after failure", async () => { + let calls = 0; + const models = new ModelManager(); + models.define("flaky", { + load: async () => { + calls += 1; + if (calls === 1) throw new Error("nope"); + return "ok"; + }, + }); + await assert.rejects(() => models.load("flaky")); + assert.equal(models.status("flaky").status, "error"); + assert.equal(await models.load("flaky"), "ok"); + assert.equal(calls, 2); + }); + + it("preload loads many ids", async () => { + const models = new ModelManager(); + models.define("a", { load: async () => "a" }); + models.define("b", { load: async () => "b" }); + await models.preload(["a", "b"]); + assert.deepEqual(models.getSnapshot().ready.sort(), ["a", "b"]); + }); + + it("rejects load of unknown id", async () => { + const models = new ModelManager(); + await assert.rejects(() => models.load("missing"), /unknown model/); + }); +}); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 7566819..29136a4 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,7 +20,7 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; -import { Weightlift } from "weightlift"; +import { ModelManager } from "weightlift"; import { transformersAdapter } from "weightlift/adapters/transformers"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; @@ -59,27 +59,6 @@ type AsrChunk = { text: string; timestamp: [number, number | null] }; const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); -/** - * Aggregate model download progress into a single 0..1 value via weightlift. - * - * Prefer transformers.js `progress_total` events: those are pre-seeded with - * every expected file's size, so the bar does not jump to 100% after the first - * ONNX file finishes while larger siblings are still downloading. Fall back to - * per-file `progress` when totals are unavailable. - */ -function makeDownloadTracker(label: string) { - const wl = new Weightlift(); - wl.start(label); - wl.subscribe((s) => { - post({ - type: "progress", - message: s.message || label, - value: s.indeterminate ? null : s.percent, - }); - }); - return transformersAdapter(wl, { message: label }); -} - /** * Whether a model's weights are already in the transformers.js browser cache. * Used purely to label the progress UI accurately ("Loading … from cache" @@ -114,45 +93,64 @@ async function pickDevice(): Promise<"webgpu" | "wasm"> { return "wasm"; } -// Keyed by model id so choices that share weights reuse one pipeline. -const asrPromises = new Map>(); +/** + * Singleton ASR pipelines + download progress, keyed by Hugging Face model id. + * Concurrent getAsr() calls share one promise; unload() after a WebGPU loss + * forces a clean reload on WASM. + */ +const asrModels = new ModelManager(); +asrModels.subscribe((snap) => { + const id = snap.loading[0]; + if (!id) return; + const rec = snap.models[id]; + if (!rec) return; + post({ + type: "progress", + message: rec.message, + value: rec.indeterminate ? null : rec.percent, + }); +}); + async function getAsr(choice: WhisperModel) { const { id, dtype } = MODELS[choice]; - let promise = asrPromises.get(id); - if (!promise) { - const device = await pickDevice(); - asrDevice = device; - const label = (await isModelCached(id)) - ? "Loading speech model from cache…" - : "Downloading speech model…"; - promise = pipeline("automatic-speech-recognition", id, { - dtype: dtype[device], - device, - progress_callback: makeDownloadTracker(label), - }).catch((err) => { - // WebGPU can fail on some drivers; retry once on plain WASM. - if (device === "webgpu") { - forceWasm = true; - asrDevice = "wasm"; - return pipeline("automatic-speech-recognition", id, { - dtype: dtype.wasm, - device: "wasm", - progress_callback: makeDownloadTracker(label), - }); + return asrModels.load(id, { + isCached: () => isModelCached(id), + messages: { + download: "Downloading speech model…", + cache: "Loading speech model from cache…", + }, + load: async ({ progress }) => { + const device = await pickDevice(); + asrDevice = device; + const onProgress = transformersAdapter(progress); + try { + return (await pipeline("automatic-speech-recognition", id, { + dtype: dtype[device], + device, + progress_callback: onProgress, + })) as AutomaticSpeechRecognitionPipeline; + } catch (err) { + // WebGPU can fail on some drivers; retry once on plain WASM. + if (device === "webgpu") { + forceWasm = true; + asrDevice = "wasm"; + return (await pipeline("automatic-speech-recognition", id, { + dtype: dtype.wasm, + device: "wasm", + progress_callback: onProgress, + })) as AutomaticSpeechRecognitionPipeline; + } + throw err; } - throw err; - }) as Promise; - asrPromises.set(id, promise); - promise.catch(() => asrPromises.delete(id)); - } - return promise; + }, + }); } /** Drop the dead WebGPU pipeline and reload the same model on WASM. */ async function fallbackAsrToWasm(choice: WhisperModel) { forceWasm = true; asrDevice = "wasm"; - asrPromises.clear(); + await Promise.all(Object.values(MODELS).map((m) => asrModels.unload(m.id))); post({ type: "progress", message: "GPU interrupted — continuing on CPU…", From 026d35e1ccf399d47a46a2b4fb445d26e4cb8bbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 21:16:54 +0000 Subject: [PATCH 03/11] Drop status messages and runtime adapters from weightlift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI copy and Transformers.js/WebLLM adapters are implementer concerns — keep ModelManager focused on lifecycle, bytes, and fromCache. Map progress callbacks at the call site; works the same for Whisper, CLIP, SigLIP, or any other T. Co-authored-by: Wassim Gharbi --- packages/weightlift/README.md | 139 +++++++----------- packages/weightlift/package.json | 20 +-- packages/weightlift/src/adapters/index.ts | 10 -- .../weightlift/src/adapters/transformers.ts | 91 ------------ packages/weightlift/src/adapters/webllm.ts | 66 --------- packages/weightlift/src/index.ts | 36 ++--- packages/weightlift/src/manager.ts | 55 ++----- packages/weightlift/src/react.ts | 28 +--- packages/weightlift/src/reduce.ts | 34 ++--- packages/weightlift/src/store.ts | 32 ++-- packages/weightlift/src/types.ts | 21 ++- packages/weightlift/src/worker.ts | 34 +---- packages/weightlift/tests/manager-test.ts | 19 +-- packages/weightlift/tests/store-test.ts | 52 +------ packages/weightlift/tests/worker-test.ts | 12 +- workers/transcription.worker.ts | 51 ++++++- 16 files changed, 191 insertions(+), 509 deletions(-) delete mode 100644 packages/weightlift/src/adapters/index.ts delete mode 100644 packages/weightlift/src/adapters/transformers.ts delete mode 100644 packages/weightlift/src/adapters/webllm.ts diff --git a/packages/weightlift/README.md b/packages/weightlift/README.md index 069ba1c..d39f6d0 100644 --- a/packages/weightlift/README.md +++ b/packages/weightlift/README.md @@ -1,139 +1,108 @@ # weightlift -In-browser **ML model manager** with download progress — for Transformers.js, -WebLLM, whisper.cpp WASM, and friends. +In-browser **ML model manager** with download progress. -The thing every app hand-rolls: singleton loads by model id, cache-hit -labeling, multi-file progress aggregation, `isLoading` / `isReady`, and -shuttling state out of a web worker. Zero runtime dependencies; React is an -optional peer. +Owns lifecycle and bytes — not UI copy, not a specific ML runtime. ```ts import { ModelManager } from "weightlift"; -import { transformersAdapter } from "weightlift/adapters/transformers"; const models = new ModelManager(); -models.define("whisper-base", { - isCached: () => isOnnxCached("onnx-community/whisper-base_timestamped"), - messages: { - download: "Downloading speech model…", - cache: "Loading speech model from cache…", - }, +models.define("siglip", { + isCached: () => isOnnxCached(modelId), load: async ({ progress }) => - pipeline("automatic-speech-recognition", "onnx-community/whisper-base_timestamped", { - progress_callback: transformersAdapter(progress), + pipeline("zero-shot-image-classification", modelId, { + progress_callback: (p) => { + if (p.status === "progress_total" && p.total) { + progress.dispatch({ + type: "progress_total", + loaded: ((p.progress ?? 0) / 100) * p.total, + total: p.total, + }); + } else if (p.status === "progress" && p.file) { + progress.dispatch({ + type: "progress", + file: p.file, + loaded: p.loaded ?? 0, + total: p.total, + }); + } + }, }), }); -const asr = await models.load("whisper-base"); -// concurrent load("whisper-base") shares the same promise +const clip = await models.load("siglip"); ``` +Works the same for Whisper, CLIP, SigLIP, WebLLM, custom ONNX — `T` is whatever your `load` returns. + ## Install ```bash npm install weightlift ``` -## Package exports +## Exports | Import | What | | --- | --- | -| `weightlift` | **`ModelManager`** (headline API), plus low-level `Weightlift` progress store | -| `weightlift/react` | `useModel` / `useModelManager` (`useSyncExternalStore`) | -| `weightlift/adapters/transformers` | `transformersAdapter()` for `progress_callback` | -| `weightlift/adapters/webllm` | `webllmAdapter()` for `initProgressCallback` | -| `weightlift/worker` | `createWorkerReporter` / `attachWorker` postMessage bridge | +| `weightlift` | `ModelManager`, low-level `Weightlift` progress store | +| `weightlift/react` | `useModel` / `useModelManager` | +| `weightlift/worker` | `createWorkerReporter` / `attachWorker` | + +No runtime adapters — map your callback into `progress.dispatch(...)` (a few lines; see above). UI strings stay in the app: compose from `status` + `fromCache`. ## ModelManager | Method | Purpose | | --- | --- | | `define(id, definition)` | Register how to load (and optionally dispose) a model | -| `load(id)` / `load(id, definition)` | Load once; dedupe concurrent callers; lazy-define overload | +| `load(id)` / `load(id, definition)` | Load once; dedupe concurrent callers | | `get(id)` | Sync access to a ready instance | -| `status(id)` | `{ status, percent, message, fromCache, … }` for UI | +| `status(id)` | `{ status, percent, fromCache, loadedBytes, … }` | | `isReady` / `isLoading` / `has` | Quick queries | -| `unload(id)` | Drop instance (calls `dispose`) so the next `load` re-runs | +| `unload(id)` | Drop instance (calls `dispose`) | | `preload([ids])` | Warm several models | -| `subscribe` / `getSnapshot` | Manager-wide snapshot for React / vanilla UI | +| `subscribe` / `getSnapshot` | Manager-wide snapshot | -### Definition shape +### Definition ```ts interface ModelDefinition { load: (ctx: { id: string; - progress: Weightlift; // pass to transformersAdapter(progress) + progress: Weightlift; fromCache: boolean | null; }) => Promise; - isCached?: () => boolean | Promise; + isCached?: () => boolean | Promise; // → ModelRecord.fromCache dispose?: (value: T) => void | Promise; - messages?: { download?: string; cache?: string; ready?: string }; } ``` -## React - -```tsx -import { useModel, useModelManager } from "weightlift/react"; - -function ModelBar({ manager }: { manager: ModelManager }) { - const { percent, message, isLoading, load } = useModel(manager, "whisper-base"); - - return ( -
- {isLoading ? ( - <> -

{message}

- - - ) : ( - - )} -
- ); -} -``` - -## Web worker - -Loads belong in a worker; the UI lives on the main thread. Each model’s -`progress` store can be bridged with `weightlift/worker`: +### UI copy belongs in the app ```ts -// worker.ts -const models = new ModelManager(); -models.define("whisper-base", { - load: async ({ progress }) => { - createWorkerReporter(self, progress, { mode: "event" }); - return pipeline(task, id, { - progress_callback: transformersAdapter(progress), - }); - }, -}); - -// main.ts -const progress = new Weightlift(); -attachWorker(worker, progress); +const { status, fromCache, percent } = models.status("siglip"); +const label = + status === "loading" + ? fromCache + ? "Loading vision model from cache…" + : "Downloading vision model…" + : ""; ``` -Or subscribe to the manager in the worker and `postMessage` `manager.getSnapshot()` yourself — the snapshot is plain JSON-serializable fields (aside from `error`). - -## Low-level progress store - -`Weightlift` is the per-model progress engine under the manager (also usable -standalone if you only need a progress bar). It aggregates parallel file -downloads into `{ percent, loadedBytes, totalBytes, indeterminate, files }`, -prefers transformers.js `progress_total` events, and stays indeterminate when -`Content-Length` is missing. +## React -## Why not React-first? +```tsx +import { useModel } from "weightlift/react"; -Hooks cannot run inside web workers, and that is where model loading happens. -Vanilla core + `useSyncExternalStore` bindings is the same pattern as Zustand / -TanStack Query — one core, many frameworks. +function Bar({ manager }: { manager: ModelManager }) { + const { percent, fromCache, isLoading, load } = useModel(manager, "siglip"); + const label = fromCache ? "Loading from cache…" : "Downloading…"; + // … +} +``` ## License diff --git a/packages/weightlift/package.json b/packages/weightlift/package.json index abdc0a7..a4c032a 100644 --- a/packages/weightlift/package.json +++ b/packages/weightlift/package.json @@ -1,7 +1,7 @@ { "name": "weightlift", "version": "0.1.0", - "description": "In-browser ML model manager with download progress (Transformers.js, WebLLM, and friends).", + "description": "In-browser ML model manager with download progress.", "license": "MIT", "author": { "name": "wassgha", @@ -15,13 +15,13 @@ "keywords": [ "ml", "model-manager", - "transformers.js", - "webllm", - "whisper", "onnx", "progress", "model-loading", - "web-worker" + "web-worker", + "transformers.js", + "clip", + "whisper" ], "type": "module", "sideEffects": false, @@ -38,16 +38,6 @@ "import": "./dist/react.js", "default": "./dist/react.js" }, - "./adapters/transformers": { - "types": "./dist/adapters/transformers.d.ts", - "import": "./dist/adapters/transformers.js", - "default": "./dist/adapters/transformers.js" - }, - "./adapters/webllm": { - "types": "./dist/adapters/webllm.d.ts", - "import": "./dist/adapters/webllm.js", - "default": "./dist/adapters/webllm.js" - }, "./worker": { "types": "./dist/worker.d.ts", "import": "./dist/worker.js", diff --git a/packages/weightlift/src/adapters/index.ts b/packages/weightlift/src/adapters/index.ts deleted file mode 100644 index d280de0..0000000 --- a/packages/weightlift/src/adapters/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - transformersAdapter, - transformersEvent, - type TransformersProgressInfo, -} from "./transformers.js"; -export { - webllmAdapter, - webllmEvent, - type WebLLMInitProgressReport, -} from "./webllm.js"; diff --git a/packages/weightlift/src/adapters/transformers.ts b/packages/weightlift/src/adapters/transformers.ts deleted file mode 100644 index 2e7e381..0000000 --- a/packages/weightlift/src/adapters/transformers.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { WeightliftEvent } from "../types.js"; - -/** Anything that can accept normalized events (store or worker reporter). */ -export interface EventSink { - dispatch(event: WeightliftEvent): void; -} - -/** - * Subset of the transformers.js `ProgressInfo` union that carries download - * progress. Kept structural so weightlift does not depend on - * `@huggingface/transformers` at runtime. - * - * @see https://huggingface.co/docs/transformers.js/guides/progress - */ -export interface TransformersProgressInfo { - status?: string; - file?: string; - name?: string; - loaded?: number; - total?: number; - progress?: number; -} - -/** - * Convert a transformers.js `progress_callback` argument into a normalized - * {@link WeightliftEvent}, or `null` if the event should be ignored. - */ -export function transformersEvent( - p: TransformersProgressInfo -): WeightliftEvent | null { - const status = p.status; - - if (status === "initiate" && p.file) { - return { type: "initiate", file: p.file }; - } - - if (status === "progress_total") { - const total = p.total ?? 0; - if (!(total > 0)) return null; - const loaded = - typeof p.progress === "number" - ? (p.progress / 100) * total - : (p.loaded ?? 0); - return { type: "progress_total", loaded, total }; - } - - if (status === "progress" && p.file) { - return { - type: "progress", - file: p.file, - loaded: p.loaded ?? 0, - total: p.total, - }; - } - - if (status === "done" && p.file) { - return { type: "done", file: p.file }; - } - - if (status === "ready") { - return { type: "ready" }; - } - - return null; -} - -/** - * Build a `progress_callback` suitable for `pipeline()` / `from_pretrained()`. - * - * ```ts - * const wl = new Weightlift(); - * wl.start("Downloading speech model…"); - * await pipeline("automatic-speech-recognition", modelId, { - * progress_callback: transformersAdapter(wl), - * }); - * wl.ready(); - * ``` - */ -export function transformersAdapter( - sink: EventSink, - options?: { message?: string } -): (progress: TransformersProgressInfo) => void { - if (options?.message) { - sink.dispatch({ type: "message", message: options.message }); - } - - return (progress) => { - const event = transformersEvent(progress); - if (event) sink.dispatch(event); - }; -} diff --git a/packages/weightlift/src/adapters/webllm.ts b/packages/weightlift/src/adapters/webllm.ts deleted file mode 100644 index 0d8b810..0000000 --- a/packages/weightlift/src/adapters/webllm.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { WeightliftEvent } from "../types.js"; - -/** Anything that can accept normalized events (store or worker reporter). */ -export interface EventSink { - dispatch(event: WeightliftEvent): void; -} - -/** - * Shape of WebLLM / MLC `InitProgressReport`. - * @see https://webllm.mlc.ai/ - */ -export interface WebLLMInitProgressReport { - /** 0..1 progress fraction. */ - progress: number; - /** Human-readable status text. */ - text?: string; - timeElapsed?: number; -} - -/** - * Convert a WebLLM `initProgressCallback` report into a normalized event. - * - * WebLLM exposes a single aggregate fraction (not per-file bytes), so this - * maps onto `progress_total` with a synthetic 100-unit total. - */ -export function webllmEvent( - report: WebLLMInitProgressReport -): WeightliftEvent { - const progress = Math.min(1, Math.max(0, report.progress || 0)); - const total = 100; - const loaded = progress * total; - if (progress >= 1) { - return { - type: "progress_total", - loaded: total, - total, - message: report.text, - }; - } - return { - type: "progress_total", - loaded, - total, - message: report.text, - }; -} - -/** - * Build an `initProgressCallback` for WebLLM's `CreateMLCEngine` / similar. - * - * ```ts - * const wl = new Weightlift(); - * wl.start("Loading LLM…"); - * const engine = await CreateMLCEngine(modelId, { - * initProgressCallback: webllmAdapter(wl), - * }); - * wl.ready(); - * ``` - */ -export function webllmAdapter( - sink: EventSink -): (report: WebLLMInitProgressReport) => void { - return (report) => { - sink.dispatch(webllmEvent(report)); - }; -} diff --git a/packages/weightlift/src/index.ts b/packages/weightlift/src/index.ts index 83a0cc4..53aa3ea 100644 --- a/packages/weightlift/src/index.ts +++ b/packages/weightlift/src/index.ts @@ -3,19 +3,30 @@ * * @packageDocumentation * + * Runtime-agnostic: Whisper, CLIP, SigLIP, WebLLM, custom ONNX — anything + * that can resolve a promise and optionally report byte progress. + * * ```ts * import { ModelManager } from "weightlift"; - * import { transformersAdapter } from "weightlift/adapters/transformers"; * * const models = new ModelManager(); - * models.define("whisper-base", { - * load: async ({ progress }) => - * pipeline("automatic-speech-recognition", modelId, { - * progress_callback: transformersAdapter(progress), - * }), + * models.define("siglip", { + * load: async ({ progress }) => { + * return pipeline("zero-shot-image-classification", modelId, { + * progress_callback: (p) => { + * if (p.status === "progress_total" && p.total) { + * progress.dispatch({ + * type: "progress_total", + * loaded: ((p.progress ?? 0) / 100) * p.total, + * total: p.total, + * }); + * } + * }, + * }); + * }, * }); * - * const asr = await models.load("whisper-base"); + * const clip = await models.load("siglip"); * ``` */ @@ -49,14 +60,3 @@ export { type WeightliftEvent, type WeightliftListener, } from "./types.js"; - -export { - transformersAdapter, - transformersEvent, - type TransformersProgressInfo, -} from "./adapters/transformers.js"; -export { - webllmAdapter, - webllmEvent, - type WebLLMInitProgressReport, -} from "./adapters/webllm.js"; diff --git a/packages/weightlift/src/manager.ts b/packages/weightlift/src/manager.ts index d4cfa79..0cb4836 100644 --- a/packages/weightlift/src/manager.ts +++ b/packages/weightlift/src/manager.ts @@ -1,20 +1,18 @@ import { Weightlift, type Unsubscribe } from "./store.js"; import type { LoadStatus, WeightliftState } from "./types.js"; -import { INITIAL_STATE } from "./types.js"; /** Context passed to a model definition's `load` function. */ export interface LoadContext { /** Stable model key passed to {@link ModelManager.load}. */ id: string; /** - * Per-model progress store. Pass to a runtime adapter: - * `progress_callback: transformersAdapter(progress)`. + * Per-model progress store. Map your runtime's progress callback into + * `progress.dispatch({ type: "progress_total" | "progress" | … })`. */ progress: Weightlift; /** * Result of `isCached()` when provided; `null` if not checked. - * Useful for branching load strategy; the manager already sets the - * download vs cache message before `load` runs. + * Compose UI copy from this in the app — weightlift does not own strings. */ fromCache: boolean | null; /** Optional abort signal if the manager starts supporting cancellation. */ @@ -27,18 +25,12 @@ export interface ModelDefinition { load: (ctx: LoadContext) => Promise; /** * Detect whether weights are already in a browser cache (Cache Storage, - * IndexedDB, etc.). Used only for messaging (`messages.cache` vs - * `messages.download`) — transformers.js emits the same progress events - * for cache reads and network downloads. + * IndexedDB, etc.). Exposed on {@link ModelRecord.fromCache} so the app + * can choose its own labeling; does not affect load behavior. */ isCached?: () => boolean | Promise; /** Release GPU/WASM resources when {@link ModelManager.unload} is called. */ dispose?: (value: T) => void | Promise; - messages?: { - download?: string; - cache?: string; - ready?: string; - }; } /** UI-facing per-model record (no heavyweight model handle). */ @@ -47,7 +39,6 @@ export interface ModelRecord { status: LoadStatus; /** `true` / `false` after cache check; `null` if not checked or idle. */ fromCache: boolean | null; - message: string; percent: number | null; indeterminate: boolean; loadedBytes: number; @@ -86,7 +77,6 @@ function recordFrom(entry: Entry): ModelRecord { id: entry.id, status: p.status, fromCache: entry.fromCache, - message: p.message, percent: p.percent, indeterminate: p.indeterminate, loadedBytes: p.loadedBytes, @@ -101,7 +91,6 @@ function idleRecord(id: string): ModelRecord { id, status: "idle", fromCache: null, - message: "", percent: null, indeterminate: true, loadedBytes: 0, @@ -137,27 +126,22 @@ const EMPTY_SNAPSHOT: ManagerSnapshot = { * In-browser ML **model manager**: define loaders once, then * `load` / `get` / `unload` by id with shared progress state. * - * Handles the things every Transformers.js / WebLLM app hand-rolls: - * singleton promises (deduped concurrent loads), cache-hit labeling, - * aggregated download progress, and `isLoading` / `isReady` queries. + * Runtime-agnostic — `T` can be a Whisper pipeline, CLIP/SigLIP encoder, + * WebLLM engine, or any other handle. weightlift only owns lifecycle + bytes. * * ```ts * const models = new ModelManager(); * - * models.define("whisper-base", { - * isCached: () => caches.match(…), - * messages: { - * download: "Downloading speech model…", - * cache: "Loading speech model from cache…", - * }, + * models.define("siglip", { * load: async ({ progress }) => - * pipeline("automatic-speech-recognition", "onnx-community/whisper-base", { - * progress_callback: transformersAdapter(progress), + * pipeline("zero-shot-image-classification", modelId, { + * progress_callback: (p) => { + * // map runtime events → progress.dispatch(...) + * }, * }), * }); * - * const asr = await models.load("whisper-base"); - * models.subscribe((s) => console.log(s.models["whisper-base"]?.percent)); + * const clip = await models.load("siglip"); * ``` */ export class ModelManager { @@ -175,8 +159,6 @@ export class ModelManager { } if (existing) { existing.unsubProgress(); - // Keep a ready value only if the caller is intentionally replacing the - // definition — drop the cached instance so the new loader runs. void this.#disposeEntry(existing); } @@ -235,12 +217,7 @@ export class ModelManager { } } entry.fromCache = fromCache; - - const message = - fromCache === true - ? (definition.messages?.cache ?? "Loading model from cache…") - : (definition.messages?.download ?? "Downloading model…"); - progress.start(message); + progress.start(); this.#emit(); try { @@ -250,7 +227,7 @@ export class ModelManager { fromCache, }); entry.value = value; - progress.ready(definition.messages?.ready); + progress.ready(); this.#emit(); return value; } catch (err) { @@ -288,7 +265,7 @@ export class ModelManager { return entry ? recordFrom(entry) : idleRecord(id); } - /** Underlying progress store for adapters / advanced use. */ + /** Underlying progress store for advanced use. */ progress(id: string): Weightlift | undefined { return this.#entries.get(id)?.progress; } diff --git a/packages/weightlift/src/react.ts b/packages/weightlift/src/react.ts index e80ccde..aa9633b 100644 --- a/packages/weightlift/src/react.ts +++ b/packages/weightlift/src/react.ts @@ -28,13 +28,7 @@ export { } from "./store.js"; export { ModelManager } from "./manager.js"; -/** - * Subscribe to the full {@link ModelManager} snapshot. - * - * ```tsx - * const { models, loading } = useModelManager(manager); - * ``` - */ +/** Subscribe to the full {@link ModelManager} snapshot. */ export function useModelManager(manager: ModelManager): ManagerSnapshot { return useSyncExternalStore( manager.subscribe, @@ -46,9 +40,8 @@ export function useModelManager(manager: ModelManager): ManagerSnapshot { /** * Subscribe to one model id on a {@link ModelManager}. * - * ```tsx - * const { status, percent, message, value, load } = useModel(manager, "whisper-base"); - * ``` + * Compose UI labels from `status` / `fromCache` in the component — the + * package does not ship copy. */ export function useModel( manager: ModelManager, @@ -58,7 +51,6 @@ export function useModel( isReady: boolean; isIdle: boolean; hasError: boolean; - /** Ready model instance, or `undefined`. */ value: T | undefined; load: () => Promise; unload: () => Promise; @@ -75,24 +67,18 @@ export function useModel( isReady: record.status === "ready", isIdle: record.status === "idle", hasError: record.status === "error", - // Read through to the manager so the handle appears once status is ready. value: manager.get(id), load, unload, }; } -/** - * Create a stable {@link ModelManager} for the lifetime of the component. - */ +/** Stable {@link ModelManager} for the lifetime of the component. */ export function useModelManagerStore(): ModelManager { return useMemo(() => new ModelManager(), []); } -/** - * Subscribe a React component to a low-level {@link Weightlift} progress store. - * Prefer {@link useModel} / {@link useModelManager} for the manager API. - */ +/** Low-level progress store subscription. Prefer {@link useModel}. */ export function useWeightlift(weightlift: Weightlift): WeightliftState & { isLoading: boolean; isReady: boolean; @@ -114,10 +100,6 @@ export function useWeightlift(weightlift: Weightlift): WeightliftState & { }; } -/** - * Create a stable {@link Weightlift} instance for the lifetime of the component. - * Pass an optional `key` to reset when the model id changes. - */ export function useWeightliftStore(key?: string): Weightlift { return useMemo(() => new Weightlift(), [key]); } diff --git a/packages/weightlift/src/reduce.ts b/packages/weightlift/src/reduce.ts index 67a3b83..b71a408 100644 --- a/packages/weightlift/src/reduce.ts +++ b/packages/weightlift/src/reduce.ts @@ -6,7 +6,7 @@ import { } from "./types.js"; interface InternalTotals { - /** Prefer runtime aggregate events when the adapter emits them. */ + /** Prefer runtime aggregate events when the caller emits them. */ useTotalEvents: boolean; /** Highest percent observed; only moves forward except when totals grow. */ best: number; @@ -38,9 +38,8 @@ function sumFiles(files: Record): { totalBytes: number | null; } { // Only files that reported a Content-Length contribute to the percentage. - // Files without a total are ignored for the bar (matches how browsers / - // transformers.js UIs typically behave) — if *no* file has a total, the - // store stays indeterminate. + // Files without a total are ignored for the bar — if *no* file has a total, + // the store stays indeterminate. let loadedBytes = 0; let totalBytes = 0; let anyTotal = false; @@ -51,7 +50,6 @@ function sumFiles(files: Record): { loadedBytes += Math.min(f.loaded, f.total); totalBytes += f.total; } else if (f.status === "done") { - // Done with unknown total: count bytes we saw so the bar can finish. loadedBytes += f.loaded; totalBytes += f.loaded; if (f.loaded > 0) anyTotal = true; @@ -101,9 +99,6 @@ function withDerived( const reported = reportPercent(ctx, loadedBytes, totalBytes); next.percent = reported.percent; next.indeterminate = reported.indeterminate; - } else if (Object.keys(next.files).length === 0) { - next.percent = null; - next.indeterminate = true; } else { next.percent = null; next.indeterminate = true; @@ -122,21 +117,13 @@ export function reduce( switch (event.type) { case "reset": ctx.totals = { useTotalEvents: false, best: 0, lastTotal: 0 }; - return { ...INITIAL_STATE }; + return { ...INITIAL_STATE, files: {} }; case "start": - return withDerived( - state, - ctx, - { - status: "loading", - message: event.message ?? state.message, - error: null, - } - ); - - case "message": - return { ...state, message: event.message }; + return withDerived(state, ctx, { + status: "loading", + error: null, + }); case "initiate": { const files = cloneFiles(state.files); @@ -179,7 +166,6 @@ export function reduce( return { ...state, status: "loading", - message: event.message ?? state.message, loadedBytes: event.loaded, totalBytes: event.total, percent: reported.percent, @@ -203,8 +189,7 @@ export function reduce( return { ...state, status: "ready", - message: event.message ?? state.message, - percent: state.indeterminate && state.percent == null ? 1 : (state.percent ?? 1), + percent: state.percent ?? 1, indeterminate: false, error: null, }; @@ -213,7 +198,6 @@ export function reduce( return { ...state, status: "error", - message: event.message ?? event.error.message, error: event.error, }; diff --git a/packages/weightlift/src/store.ts b/packages/weightlift/src/store.ts index a17543a..4252d3a 100644 --- a/packages/weightlift/src/store.ts +++ b/packages/weightlift/src/store.ts @@ -9,11 +9,11 @@ import { export type Unsubscribe = () => void; /** - * Framework-agnostic model-load progress store. + * Per-model progress store. Framework-agnostic; safe to use in a web worker. * - * Works in vanilla JS, Vue, Svelte, and — importantly — inside a web worker, - * where model loading actually happens. Feed it normalized {@link WeightliftEvent}s - * (directly or via a runtime adapter); subscribe for a single aggregated state. + * Feed it normalized {@link WeightliftEvent}s from whatever runtime you use + * (Transformers.js `progress_callback`, WebLLM `initProgressCallback`, a + * custom fetch loop, …). The {@link ModelManager} owns one of these per id. */ export class Weightlift { #state: WeightliftState = { ...INITIAL_STATE, files: {} }; @@ -47,20 +47,20 @@ export class Weightlift { } } - /** Convenience: mark loading started with an optional label. */ - start(message?: string): void { - this.dispatch({ type: "start", message }); + /** Mark loading started. */ + start(): void { + this.dispatch({ type: "start" }); } - /** Convenience: mark the model ready. */ - ready(message?: string): void { - this.dispatch({ type: "ready", message }); + /** Mark the model ready. */ + ready(): void { + this.dispatch({ type: "ready" }); } - /** Convenience: record a failure. */ - fail(error: unknown, message?: string): void { + /** Record a failure. */ + fail(error: unknown): void { const err = error instanceof Error ? error : new Error(String(error)); - this.dispatch({ type: "error", error: err, message }); + this.dispatch({ type: "error", error: err }); } /** Reset to idle (e.g. before loading a different model). */ @@ -68,9 +68,6 @@ export class Weightlift { this.dispatch({ type: "reset" }); } - /** - * Shorthand getters matching the common `isLoading` / `isReady` UI pattern. - */ get status(): WeightliftState["status"] { return this.#state.status; } @@ -94,12 +91,11 @@ export class Weightlift { /** * Deduplicating loader for a single model. Prefer {@link ModelManager} when - * you manage more than one id (cache labels, unload, manager snapshots). + * you manage more than one id. */ export function createModelLoader(loadFn: (wl: Weightlift) => Promise): { weightlift: Weightlift; load: () => Promise; - /** Drop the cached promise so the next `load()` runs again. */ invalidate: () => void; } { const weightlift = new Weightlift(); diff --git a/packages/weightlift/src/types.ts b/packages/weightlift/src/types.ts index 19e2fd6..daa1872 100644 --- a/packages/weightlift/src/types.ts +++ b/packages/weightlift/src/types.ts @@ -10,16 +10,17 @@ export interface FileProgress { } /** - * Aggregated model-load state. + * Aggregated model-load progress. * * `percent` is `null` (and `indeterminate` is true) when no usable byte total * is known yet — files can download in parallel and some servers omit * Content-Length, so a single 0..1 bar is not always available. + * + * UI copy ("Downloading…") is intentionally not part of this state — compose + * labels in the app from `status` / `fromCache` on {@link ModelRecord}. */ export interface WeightliftState { status: LoadStatus; - /** Human-readable label, e.g. "Downloading speech model…". */ - message: string; files: Record; loadedBytes: number; /** Sum of known file totals; `null` if none reported a total. */ @@ -31,11 +32,11 @@ export interface WeightliftState { } /** - * Normalized progress events. Runtime adapters convert Transformers.js / - * WebLLM / whisper.cpp callbacks into this shape before feeding the store. + * Normalized progress events. Map your runtime's callback into these and + * `dispatch` them — Transformers.js, WebLLM, whisper.cpp, custom fetch, etc. */ export type WeightliftEvent = - | { type: "start"; message?: string } + | { type: "start" } | { type: "initiate"; file: string } | { type: "progress"; @@ -49,18 +50,16 @@ export type WeightliftEvent = * Preferred over summing per-file events when available — totals are often * pre-seeded so the bar does not jump to 100% after the first file finishes. */ - | { type: "progress_total"; loaded: number; total: number; message?: string } + | { type: "progress_total"; loaded: number; total: number } | { type: "done"; file: string } - | { type: "ready"; message?: string } - | { type: "error"; error: Error; message?: string } - | { type: "message"; message: string } + | { type: "ready" } + | { type: "error"; error: Error } | { type: "reset" }; export type WeightliftListener = (state: WeightliftState) => void; export const INITIAL_STATE: WeightliftState = { status: "idle", - message: "", files: {}, loadedBytes: 0, totalBytes: null, diff --git a/packages/weightlift/src/worker.ts b/packages/weightlift/src/worker.ts index cab6992..f72b57a 100644 --- a/packages/weightlift/src/worker.ts +++ b/packages/weightlift/src/worker.ts @@ -12,7 +12,7 @@ export type WeightliftWorkerMessage = state: WeightliftState; }; -/** Minimal sink accepted by adapters (`transformersAdapter`, etc.). */ +/** Minimal sink that accepts normalized events (store or reporter). */ export interface EventSink { dispatch(event: WeightliftEvent): void; } @@ -40,18 +40,8 @@ export interface WorkerReporter { * Worker-side helper: every update is forwarded to the main thread as a * serializable {@link WeightliftWorkerMessage}. * - * Default `mode: "state"` posts snapshots (cheap JSON, easy UI binding). - * `mode: "event"` forwards raw events so the main thread can rehydrate into - * its own {@link Weightlift} via {@link attachWorker} and keep reduce context. - * - * ```ts - * // worker.ts - * const wl = new Weightlift(); - * const reporter = createWorkerReporter(self, wl); - * await pipeline(task, id, { - * progress_callback: transformersAdapter(reporter), - * }); - * ``` + * Default `mode: "state"` posts snapshots. `mode: "event"` forwards raw + * events so the main thread can rehydrate via {@link attachWorker}. */ export function createWorkerReporter( post: PostMessageTarget | ((message: unknown) => void), @@ -99,13 +89,6 @@ export function createWorkerReporter( /** * Main-thread helper: listen for weightlift messages from a worker and apply * them onto a local {@link Weightlift} store. - * - * ```ts - * const wl = new Weightlift(); - * const stop = attachWorker(worker, wl); - * // … wl.subscribe(render) … - * stop(); - * ``` */ export function attachWorker( worker: { @@ -120,7 +103,6 @@ export function attachWorker( }, weightlift: Weightlift, options?: { - /** Extra callback when a full state snapshot arrives. */ onState?: (state: WeightliftState) => void; } ): () => void { @@ -143,17 +125,13 @@ function mirrorState(weightlift: Weightlift, state: WeightliftState): void { weightlift.reset(); if (state.status === "idle") return; - weightlift.dispatch({ - type: "start", - message: state.message || undefined, - }); + weightlift.start(); if (state.totalBytes != null && state.totalBytes > 0) { weightlift.dispatch({ type: "progress_total", loaded: state.loadedBytes, total: state.totalBytes, - message: state.message || undefined, }); } else { for (const [file, fp] of Object.entries(state.files)) { @@ -170,9 +148,9 @@ function mirrorState(weightlift: Weightlift, state: WeightliftState): void { } } - if (state.status === "ready") weightlift.ready(state.message || undefined); + if (state.status === "ready") weightlift.ready(); if (state.status === "error") { - weightlift.fail(state.error ?? new Error(state.message || "load failed"), state.message); + weightlift.fail(state.error ?? new Error("load failed")); } } diff --git a/packages/weightlift/tests/manager-test.ts b/packages/weightlift/tests/manager-test.ts index 015f04d..4d05c86 100644 --- a/packages/weightlift/tests/manager-test.ts +++ b/packages/weightlift/tests/manager-test.ts @@ -55,36 +55,20 @@ describe("ModelManager", () => { assert.equal(models.has("lazy"), true); }); - it("labels cache vs download via isCached + messages", async () => { + it("exposes fromCache without owning UI copy", async () => { const models = new ModelManager(); - const seen: string[] = []; - models.subscribe((s) => { - const msg = s.models.w?.message; - if (msg) seen.push(msg); - }); - await models.load("w", { isCached: async () => true, - messages: { - cache: "Loading from cache…", - download: "Downloading…", - }, load: async () => "x", }); - assert.ok(seen.includes("Loading from cache…")); assert.equal(models.status("w").fromCache, true); const models2 = new ModelManager(); await models2.load("w", { isCached: async () => false, - messages: { - cache: "Loading from cache…", - download: "Downloading…", - }, load: async () => "x", }); assert.equal(models2.status("w").fromCache, false); - assert.equal(models2.status("w").message.includes("Download") || true, true); }); it("exposes progress through the load context", async () => { @@ -103,7 +87,6 @@ describe("ModelManager", () => { return "model"; }, }); - // Let the load start. await Promise.resolve(); await Promise.resolve(); assert.ok(progress); diff --git a/packages/weightlift/tests/store-test.ts b/packages/weightlift/tests/store-test.ts index d5c4b17..8b3af11 100644 --- a/packages/weightlift/tests/store-test.ts +++ b/packages/weightlift/tests/store-test.ts @@ -5,17 +5,14 @@ import { createModelLoader, createModelRegistry, } from "../src/store.js"; -import { transformersEvent } from "../src/adapters/transformers.js"; -import { webllmEvent } from "../src/adapters/webllm.js"; describe("Weightlift", () => { it("starts idle and becomes loading on start()", () => { const wl = new Weightlift(); assert.equal(wl.status, "idle"); assert.equal(wl.isIdle, true); - wl.start("Downloading…"); + wl.start(); assert.equal(wl.isLoading, true); - assert.equal(wl.getSnapshot().message, "Downloading…"); }); it("aggregates parallel per-file progress into one percent", () => { @@ -54,7 +51,6 @@ describe("Weightlift", () => { wl.start(); wl.dispatch({ type: "progress_total", loaded: 50, total: 100 }); assert.equal(wl.getSnapshot().percent, 0.5); - // New files discovered — total doubles, loaded stays 50 → 25%. wl.dispatch({ type: "progress_total", loaded: 50, total: 200 }); assert.ok(wl.getSnapshot().percent !== null); assert.ok((wl.getSnapshot().percent as number) <= 0.5); @@ -76,13 +72,13 @@ describe("Weightlift", () => { it("ready / fail / reset lifecycle", () => { const wl = new Weightlift(); wl.start(); - wl.ready("Cached"); + wl.ready(); assert.equal(wl.isReady, true); assert.equal(wl.getSnapshot().percent, 1); - wl.fail(new Error("boom"), "Failed"); + wl.fail(new Error("boom")); assert.equal(wl.hasError, true); - assert.equal(wl.getSnapshot().message, "Failed"); + assert.equal(wl.getSnapshot().error?.message, "boom"); wl.reset(); assert.equal(wl.isIdle, true); @@ -130,43 +126,3 @@ describe("createModelRegistry", () => { assert.equal(await b.load(), "small"); }); }); - -describe("adapters", () => { - it("maps transformers.js progress_total", () => { - const event = transformersEvent({ - status: "progress_total", - progress: 25, - total: 400, - }); - assert.deepEqual(event, { - type: "progress_total", - loaded: 100, - total: 400, - }); - }); - - it("maps transformers.js per-file progress", () => { - const event = transformersEvent({ - status: "progress", - file: "model.onnx", - loaded: 10, - total: 20, - }); - assert.deepEqual(event, { - type: "progress", - file: "model.onnx", - loaded: 10, - total: 20, - }); - }); - - it("maps webllm init progress", () => { - const event = webllmEvent({ progress: 0.4, text: "Loading…" }); - assert.equal(event.type, "progress_total"); - if (event.type === "progress_total") { - assert.equal(event.loaded, 40); - assert.equal(event.total, 100); - assert.equal(event.message, "Loading…"); - } - }); -}); diff --git a/packages/weightlift/tests/worker-test.ts b/packages/weightlift/tests/worker-test.ts index d3de5b6..6b15f71 100644 --- a/packages/weightlift/tests/worker-test.ts +++ b/packages/weightlift/tests/worker-test.ts @@ -14,7 +14,7 @@ describe("worker bridge", () => { const posted: unknown[] = []; const reporter = createWorkerReporter((msg) => posted.push(msg), wl); - wl.start("Downloading…"); + wl.start(); wl.dispatch({ type: "progress_total", loaded: 25, total: 100 }); assert.ok(posted.length >= 2); @@ -22,7 +22,7 @@ describe("worker bridge", () => { assert.equal(isWeightliftMessage(last), true); if (isWeightliftMessage(last) && last.kind === "state") { assert.equal(last.state.percent, 0.25); - assert.equal(last.state.message, "Downloading…"); + assert.equal(last.state.status, "loading"); } reporter.unsubscribe(); }); @@ -34,14 +34,14 @@ describe("worker bridge", () => { mode: "event", }); - reporter.dispatch({ type: "start", message: "Go" }); + reporter.dispatch({ type: "start" }); reporter.dispatch({ type: "progress_total", loaded: 1, total: 2 }); assert.equal(posted.length, 2); assert.deepEqual(posted[0], { type: WEIGHTLIFT_MESSAGE_TYPE, kind: "event", - event: { type: "start", message: "Go" }, + event: { type: "start" }, }); assert.equal(wl.getSnapshot().percent, 0.5); }); @@ -68,7 +68,7 @@ describe("worker bridge", () => { worker.emit({ type: WEIGHTLIFT_MESSAGE_TYPE, kind: "event", - event: { type: "start", message: "Hi" }, + event: { type: "start" }, }); worker.emit({ type: WEIGHTLIFT_MESSAGE_TYPE, @@ -76,7 +76,7 @@ describe("worker bridge", () => { event: { type: "progress_total", loaded: 3, total: 4 }, }); - assert.equal(main.getSnapshot().message, "Hi"); + assert.equal(main.getSnapshot().status, "loading"); assert.equal(main.getSnapshot().percent, 0.75); stop(); assert.equal(listeners.size, 0); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 29136a4..2c81685 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,8 +20,7 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; -import { ModelManager } from "weightlift"; -import { transformersAdapter } from "weightlift/adapters/transformers"; +import { ModelManager, type Weightlift } from "weightlift"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; import { cleanTranscript } from "@/lib/hallucinations"; @@ -93,6 +92,43 @@ async function pickDevice(): Promise<"webgpu" | "wasm"> { return "wasm"; } +/** + * Map transformers.js progress_callback events into a Weightlift store. + * Kept local — weightlift stays runtime-agnostic. + */ +function feedTransformersProgress(progress: Weightlift) { + return (p: { + status?: string; + file?: string; + loaded?: number; + total?: number; + progress?: number; + }) => { + if (p.status === "progress_total") { + const total = p.total ?? 0; + if (!(total > 0)) return; + const loaded = + typeof p.progress === "number" + ? (p.progress / 100) * total + : (p.loaded ?? 0); + progress.dispatch({ type: "progress_total", loaded, total }); + return; + } + if (p.status === "progress" && p.file) { + progress.dispatch({ + type: "progress", + file: p.file, + loaded: p.loaded ?? 0, + total: p.total, + }); + return; + } + if (p.status === "done" && p.file) { + progress.dispatch({ type: "done", file: p.file }); + } + }; +} + /** * Singleton ASR pipelines + download progress, keyed by Hugging Face model id. * Concurrent getAsr() calls share one promise; unload() after a WebGPU loss @@ -106,7 +142,10 @@ asrModels.subscribe((snap) => { if (!rec) return; post({ type: "progress", - message: rec.message, + message: + rec.fromCache === true + ? "Loading speech model from cache…" + : "Downloading speech model…", value: rec.indeterminate ? null : rec.percent, }); }); @@ -115,14 +154,10 @@ async function getAsr(choice: WhisperModel) { const { id, dtype } = MODELS[choice]; return asrModels.load(id, { isCached: () => isModelCached(id), - messages: { - download: "Downloading speech model…", - cache: "Loading speech model from cache…", - }, load: async ({ progress }) => { const device = await pickDevice(); asrDevice = device; - const onProgress = transformersAdapter(progress); + const onProgress = feedTransformersProgress(progress); try { return (await pipeline("automatic-speech-recognition", id, { dtype: dtype[device], From 0b73f0a6ba4de542a67e87ba1568a9dc42d6a688 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 21:40:52 +0000 Subject: [PATCH 04/11] Make weightlift a standalone package; depend on it from npm packages/weightlift is now self-contained (own gitignore, lockfile, CI, npmignore) so it can be copied into its own repo and published. Drop npm workspaces / local build glue from Rescript and consume weightlift@^0.1.0 as a normal dependency. Co-authored-by: Wassim Gharbi --- .github/workflows/ci.yml | 3 - .gitignore | 1 - next.config.ts | 2 - package-lock.json | 947 +++++++++++-------- package.json | 9 +- packages/weightlift/.github/workflows/ci.yml | 36 + packages/weightlift/.gitignore | 23 + packages/weightlift/.npmignore | 11 + packages/weightlift/README.md | 16 +- packages/weightlift/package-lock.json | 589 ++++++++++++ packages/weightlift/package.json | 14 +- packages/weightlift/tsconfig.json | 3 +- tsconfig.json | 2 +- 13 files changed, 1263 insertions(+), 393 deletions(-) create mode 100644 packages/weightlift/.github/workflows/ci.yml create mode 100644 packages/weightlift/.gitignore create mode 100644 packages/weightlift/.npmignore create mode 100644 packages/weightlift/package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccad791..54b109b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,6 @@ jobs: - name: Lint run: npm run lint - - name: Test weightlift - run: npm run test:weightlift - - name: Typecheck (web) run: | # next-env.d.ts is gitignored; generate it (and route types) before tsc. diff --git a/.gitignore b/.gitignore index 1f16148..984463f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ # electron compile + electron-builder packages /electron-dist/ /dist/ -packages/weightlift/dist/ # misc .DS_Store diff --git a/next.config.ts b/next.config.ts index 6e78ec5..f08d0fb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,8 +9,6 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const nextConfig: NextConfig = { reactStrictMode: true, - // Local workspace package (packages/weightlift) — compile from source/dist. - transpilePackages: ["weightlift"], ...(isExport ? { output: "export" as const, diff --git a/package-lock.json b/package-lock.json index 198a985..47d5b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "rescript", "version": "1.0.9", "hasInstallScript": true, - "workspaces": [ - "packages/*" - ], "dependencies": { "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", @@ -24,7 +21,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "*", + "weightlift": "^0.1.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -50,14 +47,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.158", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.158.tgz", - "integrity": "sha512-7yQXeK84iQTwlvPi+02kfde9mtr9/yMUOxcbuByK8qKLZnzRN+equz3vXk8MeSe6OW3VVWx+j24nqrwRHRv5Rw==", + "version": "3.0.161", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.161.tgz", + "integrity": "sha512-aV5j1JFKzwfq4RCuWRDerzOsNkIZ7+Pbz4aaGESW7BLbuQMGlsOJTv24+Uyksi1cg5WTEKNik6ghLfFRsU7TKw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.14", - "@ai-sdk/provider-utils": "4.0.40", + "@ai-sdk/provider-utils": "4.0.41", "@vercel/oidc": "3.2.0" }, "engines": { @@ -81,15 +78,16 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.40", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.40.tgz", - "integrity": "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw==", + "version": "4.0.41", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.41.tgz", + "integrity": "sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.8" + "eventsource-parser": "^3.0.8", + "undici": "^5.29.0" }, "engines": { "node": ">=18" @@ -167,15 +165,25 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -201,6 +209,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -288,13 +306,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -319,18 +337,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -338,9 +356,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -352,9 +370,9 @@ } }, "node_modules/@electron-internal/extract-zip": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", - "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -379,6 +397,37 @@ "node": ">=10.12.0" } }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@electron/fuses": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", @@ -431,17 +480,15 @@ "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@electron/get/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", + "optional": true, "engines": { - "node": ">=10" + "node": ">=20.18.1" } }, "node_modules/@electron/notarize": { @@ -550,10 +597,17 @@ "node": ">=16.4" } }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -1169,6 +1223,37 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -1219,6 +1304,61 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -1256,6 +1396,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@ffmpeg/core-mt": { "version": "0.12.10", "resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz", @@ -2026,22 +2176,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@next/env": { @@ -2800,9 +2953,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2810,9 +2963,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3017,58 +3170,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", @@ -3479,9 +3580,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -3521,15 +3622,15 @@ } }, "node_modules/ai": { - "version": "6.0.236", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.236.tgz", - "integrity": "sha512-qo9hEgJ11FQk/cyrug+89cBsuiiHG/a7Cwoce79J8SR18WpufmgPzEvQTDvt/kdHufGwf2oLJNVEQbBtkK3Rvg==", + "version": "6.0.239", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.239.tgz", + "integrity": "sha512-qTFRqxLxKVR0J8X1MAs0xR5nXNZbeQz5Nnhm6jeASQuADcYaZppcVE6/Fl0gKSARsT/6I5XOWWx+fhCdZl6BOw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.158", + "@ai-sdk/gateway": "3.0.161", "@ai-sdk/provider": "3.0.14", - "@ai-sdk/provider-utils": "4.0.40", + "@ai-sdk/provider-utils": "4.0.41", "@opentelemetry/api": "^1.9.0" }, "engines": { @@ -3540,16 +3641,16 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3686,46 +3787,6 @@ "semver": "bin/semver.js" } }, - "node_modules/app-builder-lib/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/app-builder-lib/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -3775,13 +3836,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -3792,22 +3846,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4134,11 +4172,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -4162,9 +4203,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", - "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", + "integrity": "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4188,14 +4229,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -4428,6 +4471,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4586,22 +4642,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4860,6 +4900,37 @@ "p-limit": "^3.1.0 " } }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/dmg-builder": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", @@ -4970,9 +5041,9 @@ } }, "node_modules/electron": { - "version": "42.7.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.7.1.tgz", - "integrity": "sha512-fmjFv4Ebbs/FNZg6a/IAu25lBb8vo/HBVa2vtE2GvYlgAC7/fu8dx1b2pL2wV1yWlfMI2q3Npe4eVYSFGirnwA==", + "version": "42.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.8.0.tgz", + "integrity": "sha512-lgeUDjUuUzUSBchBudmjCZ8ApeYdVneMi17nLRMdfxGw7FyLFligsLtIF+dL3UoNnOsupAwdqVNJCK5MFE82kQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5056,9 +5127,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.396", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", - "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -5187,9 +5258,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -5706,6 +5777,24 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -5716,6 +5805,29 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -5746,6 +5858,37 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -5799,6 +5942,47 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5829,6 +6013,61 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5962,9 +6201,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -6011,10 +6250,17 @@ "minimatch": "^5.0.1" } }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6085,9 +6331,9 @@ "license": "Apache-2.0" }, "node_modules/flatted": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", - "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -6356,6 +6602,37 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -6373,18 +6650,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -6809,19 +7074,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7250,9 +7502,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7300,9 +7552,9 @@ "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -7758,9 +8010,9 @@ } }, "node_modules/lucide-react": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", - "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -7869,16 +8121,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -8070,19 +8325,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -8093,19 +8335,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -8125,6 +8354,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -8170,19 +8409,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", @@ -8656,9 +8882,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -9283,13 +9509,15 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -9406,18 +9634,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/sharp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9550,19 +9766,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9849,16 +10052,19 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -10313,14 +10519,16 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, "engines": { - "node": ">=20.18.1" + "node": ">=14.0" } }, "node_modules/undici-types": { @@ -10476,8 +10684,21 @@ } }, "node_modules/weightlift": { - "resolved": "packages/weightlift", - "link": true + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.1.0.tgz", + "integrity": "sha512-4iGNtkdlx4JhlFmvQ2r1Dp/6MHCDonWpfmMQjSjstCZ0Qf6FoUqNF1inpd4N9Mwojf5w20c2bgq1lPiJ3r19Yg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } }, "node_modules/which": { "version": "2.0.2", @@ -10739,24 +10960,6 @@ "optional": true } } - }, - "packages/weightlift": { - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "@types/react": "^19", - "react": "19.2.4", - "tsx": "^4.22.4", - "typescript": "^5" - }, - "peerDependencies": { - "react": ">=18" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index ed43940..b0795f8 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,13 @@ "type": "git", "url": "git+https://github.com/wassgha/rescript.git" }, - "workspaces": [ - "packages/*" - ], "main": "./electron-dist/main.js", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", - "test:weightlift": "npm run test -w weightlift", - "build:weightlift": "npm run build -w weightlift", - "postinstall": "npm run build:weightlift && node scripts/copy-assets.mjs", + "postinstall": "node scripts/copy-assets.mjs", "build:electron": "node scripts/build-electron.mjs", "typecheck:electron": "tsc -p electron/tsconfig.json --noEmit", "electron:dev": "npm run build:electron && concurrently -k -n next,electron -c cyan,magenta \"next dev\" \"node scripts/electron-dev.mjs\"", @@ -47,7 +42,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "*", + "weightlift": "^0.1.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/packages/weightlift/.github/workflows/ci.yml b/packages/weightlift/.github/workflows/ci.yml new file mode 100644 index 0000000..f4a746f --- /dev/null +++ b/packages/weightlift/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build diff --git a/packages/weightlift/.gitignore b/packages/weightlift/.gitignore new file mode 100644 index 0000000..ab97cf6 --- /dev/null +++ b/packages/weightlift/.gitignore @@ -0,0 +1,23 @@ +# dependencies +node_modules/ + +# build +dist/ +*.tsbuildinfo + +# packs / publish artifacts +*.tgz + +# test / coverage +coverage/ + +# misc +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env +.env +.env.* diff --git a/packages/weightlift/.npmignore b/packages/weightlift/.npmignore new file mode 100644 index 0000000..8d917eb --- /dev/null +++ b/packages/weightlift/.npmignore @@ -0,0 +1,11 @@ +# Source and tests are on GitHub; npm only needs the build output +# (also enforced by package.json "files"). +src/ +tests/ +tsconfig.json +tsconfig.build.json +.gitignore +.github/ +*.tgz +*.tsbuildinfo +coverage/ diff --git a/packages/weightlift/README.md b/packages/weightlift/README.md index d39f6d0..d5f9f58 100644 --- a/packages/weightlift/README.md +++ b/packages/weightlift/README.md @@ -51,7 +51,7 @@ npm install weightlift | `weightlift/react` | `useModel` / `useModelManager` | | `weightlift/worker` | `createWorkerReporter` / `attachWorker` | -No runtime adapters — map your callback into `progress.dispatch(...)` (a few lines; see above). UI strings stay in the app: compose from `status` + `fromCache`. +Map your runtime’s progress callback into `progress.dispatch(...)` at the call site. UI strings stay in the app: compose from `status` + `fromCache`. ## ModelManager @@ -104,6 +104,20 @@ function Bar({ manager }: { manager: ModelManager }) { } ``` +## Develop + +```bash +npm install +npm test +npm run build +``` + +Publish: + +```bash +npm publish +``` + ## License MIT diff --git a/packages/weightlift/package-lock.json b/packages/weightlift/package-lock.json new file mode 100644 index 0000000..efb95fc --- /dev/null +++ b/packages/weightlift/package-lock.json @@ -0,0 +1,589 @@ +{ + "name": "weightlift", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "weightlift", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/react": "^19", + "react": "^19.0.0", + "tsx": "^4.22.4", + "typescript": "^5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/weightlift/package.json b/packages/weightlift/package.json index a4c032a..1635584 100644 --- a/packages/weightlift/package.json +++ b/packages/weightlift/package.json @@ -7,10 +7,13 @@ "name": "wassgha", "email": "wassgha@gmail.com" }, + "homepage": "https://github.com/wassgha/weightlift#readme", + "bugs": { + "url": "https://github.com/wassgha/weightlift/issues" + }, "repository": { "type": "git", - "url": "git+https://github.com/wassgha/rescript.git", - "directory": "packages/weightlift" + "url": "git+https://github.com/wassgha/weightlift.git" }, "keywords": [ "ml", @@ -54,7 +57,10 @@ "build": "tsc -p tsconfig.build.json", "test": "tsx --test tests/**/*-test.ts", "typecheck": "tsc -p tsconfig.build.json --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build && npm test" + }, + "engines": { + "node": ">=18" }, "peerDependencies": { "react": ">=18" @@ -66,7 +72,7 @@ }, "devDependencies": { "@types/react": "^19", - "react": "19.2.4", + "react": "^19.0.0", "tsx": "^4.22.4", "typescript": "^5" } diff --git a/packages/weightlift/tsconfig.json b/packages/weightlift/tsconfig.json index 924167b..0bd25a9 100644 --- a/packages/weightlift/tsconfig.json +++ b/packages/weightlift/tsconfig.json @@ -11,8 +11,7 @@ "isolatedModules": true, "declaration": true, "jsx": "react-jsx", - "rootDir": ".", - "types": ["node"] + "rootDir": "." }, "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/tsconfig.json b/tsconfig.json index ced6aab..def3ff8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,6 @@ "electron", "electron-dist", "dist", - "packages/weightlift" + "packages" ] } From 3cf3001b52a273332f7aa109b2e4e3d2faa3bf22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 00:41:43 +0000 Subject: [PATCH 05/11] Consume published weightlift@0.1.0 from npm Point the lockfile at the registry package and drop the local packages/weightlift copy now that it lives in its own repo. Co-authored-by: Wassim Gharbi --- package-lock.json | 2 +- packages/weightlift/.github/workflows/ci.yml | 36 -- packages/weightlift/.gitignore | 23 - packages/weightlift/.npmignore | 11 - packages/weightlift/LICENSE | 21 - packages/weightlift/README.md | 123 ---- packages/weightlift/package-lock.json | 589 ------------------- packages/weightlift/package.json | 79 --- packages/weightlift/src/index.ts | 62 -- packages/weightlift/src/manager.ts | 335 ----------- packages/weightlift/src/react.ts | 105 ---- packages/weightlift/src/reduce.ts | 207 ------- packages/weightlift/src/store.ts | 159 ----- packages/weightlift/src/types.ts | 69 --- packages/weightlift/src/worker.ts | 157 ----- packages/weightlift/tests/manager-test.ts | 158 ----- packages/weightlift/tests/store-test.ts | 128 ---- packages/weightlift/tests/worker-test.ts | 84 --- packages/weightlift/tsconfig.build.json | 13 - packages/weightlift/tsconfig.json | 17 - 20 files changed, 1 insertion(+), 2377 deletions(-) delete mode 100644 packages/weightlift/.github/workflows/ci.yml delete mode 100644 packages/weightlift/.gitignore delete mode 100644 packages/weightlift/.npmignore delete mode 100644 packages/weightlift/LICENSE delete mode 100644 packages/weightlift/README.md delete mode 100644 packages/weightlift/package-lock.json delete mode 100644 packages/weightlift/package.json delete mode 100644 packages/weightlift/src/index.ts delete mode 100644 packages/weightlift/src/manager.ts delete mode 100644 packages/weightlift/src/react.ts delete mode 100644 packages/weightlift/src/reduce.ts delete mode 100644 packages/weightlift/src/store.ts delete mode 100644 packages/weightlift/src/types.ts delete mode 100644 packages/weightlift/src/worker.ts delete mode 100644 packages/weightlift/tests/manager-test.ts delete mode 100644 packages/weightlift/tests/store-test.ts delete mode 100644 packages/weightlift/tests/worker-test.ts delete mode 100644 packages/weightlift/tsconfig.build.json delete mode 100644 packages/weightlift/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 47d5b02..98fb6b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10686,7 +10686,7 @@ "node_modules/weightlift": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.1.0.tgz", - "integrity": "sha512-4iGNtkdlx4JhlFmvQ2r1Dp/6MHCDonWpfmMQjSjstCZ0Qf6FoUqNF1inpd4N9Mwojf5w20c2bgq1lPiJ3r19Yg==", + "integrity": "sha512-PfDEDUZXrNwJdtWz3S5IKene+/tQhqNYAL+cN7TQMJHgYRP6exV9Hb3tbCO5//SQwG4qA7ZqFc/pOvyjd0MPkQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/packages/weightlift/.github/workflows/ci.yml b/packages/weightlift/.github/workflows/ci.yml deleted file mode 100644 index f4a746f..0000000 --- a/packages/weightlift/.github/workflows/ci.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm run test - - - name: Build - run: npm run build diff --git a/packages/weightlift/.gitignore b/packages/weightlift/.gitignore deleted file mode 100644 index ab97cf6..0000000 --- a/packages/weightlift/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# dependencies -node_modules/ - -# build -dist/ -*.tsbuildinfo - -# packs / publish artifacts -*.tgz - -# test / coverage -coverage/ - -# misc -.DS_Store -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env -.env -.env.* diff --git a/packages/weightlift/.npmignore b/packages/weightlift/.npmignore deleted file mode 100644 index 8d917eb..0000000 --- a/packages/weightlift/.npmignore +++ /dev/null @@ -1,11 +0,0 @@ -# Source and tests are on GitHub; npm only needs the build output -# (also enforced by package.json "files"). -src/ -tests/ -tsconfig.json -tsconfig.build.json -.gitignore -.github/ -*.tgz -*.tsbuildinfo -coverage/ diff --git a/packages/weightlift/LICENSE b/packages/weightlift/LICENSE deleted file mode 100644 index 81c0264..0000000 --- a/packages/weightlift/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Wassim Gharbi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/weightlift/README.md b/packages/weightlift/README.md deleted file mode 100644 index d5f9f58..0000000 --- a/packages/weightlift/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# weightlift - -In-browser **ML model manager** with download progress. - -Owns lifecycle and bytes — not UI copy, not a specific ML runtime. - -```ts -import { ModelManager } from "weightlift"; - -const models = new ModelManager(); - -models.define("siglip", { - isCached: () => isOnnxCached(modelId), - load: async ({ progress }) => - pipeline("zero-shot-image-classification", modelId, { - progress_callback: (p) => { - if (p.status === "progress_total" && p.total) { - progress.dispatch({ - type: "progress_total", - loaded: ((p.progress ?? 0) / 100) * p.total, - total: p.total, - }); - } else if (p.status === "progress" && p.file) { - progress.dispatch({ - type: "progress", - file: p.file, - loaded: p.loaded ?? 0, - total: p.total, - }); - } - }, - }), -}); - -const clip = await models.load("siglip"); -``` - -Works the same for Whisper, CLIP, SigLIP, WebLLM, custom ONNX — `T` is whatever your `load` returns. - -## Install - -```bash -npm install weightlift -``` - -## Exports - -| Import | What | -| --- | --- | -| `weightlift` | `ModelManager`, low-level `Weightlift` progress store | -| `weightlift/react` | `useModel` / `useModelManager` | -| `weightlift/worker` | `createWorkerReporter` / `attachWorker` | - -Map your runtime’s progress callback into `progress.dispatch(...)` at the call site. UI strings stay in the app: compose from `status` + `fromCache`. - -## ModelManager - -| Method | Purpose | -| --- | --- | -| `define(id, definition)` | Register how to load (and optionally dispose) a model | -| `load(id)` / `load(id, definition)` | Load once; dedupe concurrent callers | -| `get(id)` | Sync access to a ready instance | -| `status(id)` | `{ status, percent, fromCache, loadedBytes, … }` | -| `isReady` / `isLoading` / `has` | Quick queries | -| `unload(id)` | Drop instance (calls `dispose`) | -| `preload([ids])` | Warm several models | -| `subscribe` / `getSnapshot` | Manager-wide snapshot | - -### Definition - -```ts -interface ModelDefinition { - load: (ctx: { - id: string; - progress: Weightlift; - fromCache: boolean | null; - }) => Promise; - isCached?: () => boolean | Promise; // → ModelRecord.fromCache - dispose?: (value: T) => void | Promise; -} -``` - -### UI copy belongs in the app - -```ts -const { status, fromCache, percent } = models.status("siglip"); -const label = - status === "loading" - ? fromCache - ? "Loading vision model from cache…" - : "Downloading vision model…" - : ""; -``` - -## React - -```tsx -import { useModel } from "weightlift/react"; - -function Bar({ manager }: { manager: ModelManager }) { - const { percent, fromCache, isLoading, load } = useModel(manager, "siglip"); - const label = fromCache ? "Loading from cache…" : "Downloading…"; - // … -} -``` - -## Develop - -```bash -npm install -npm test -npm run build -``` - -Publish: - -```bash -npm publish -``` - -## License - -MIT diff --git a/packages/weightlift/package-lock.json b/packages/weightlift/package-lock.json deleted file mode 100644 index efb95fc..0000000 --- a/packages/weightlift/package-lock.json +++ /dev/null @@ -1,589 +0,0 @@ -{ - "name": "weightlift", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "weightlift", - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "@types/react": "^19", - "react": "^19.0.0", - "tsx": "^4.22.4", - "typescript": "^5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": ">=18" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/packages/weightlift/package.json b/packages/weightlift/package.json deleted file mode 100644 index 1635584..0000000 --- a/packages/weightlift/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "weightlift", - "version": "0.1.0", - "description": "In-browser ML model manager with download progress.", - "license": "MIT", - "author": { - "name": "wassgha", - "email": "wassgha@gmail.com" - }, - "homepage": "https://github.com/wassgha/weightlift#readme", - "bugs": { - "url": "https://github.com/wassgha/weightlift/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/wassgha/weightlift.git" - }, - "keywords": [ - "ml", - "model-manager", - "onnx", - "progress", - "model-loading", - "web-worker", - "transformers.js", - "clip", - "whisper" - ], - "type": "module", - "sideEffects": false, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./react": { - "types": "./dist/react.d.ts", - "import": "./dist/react.js", - "default": "./dist/react.js" - }, - "./worker": { - "types": "./dist/worker.d.ts", - "import": "./dist/worker.js", - "default": "./dist/worker.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsc -p tsconfig.build.json", - "test": "tsx --test tests/**/*-test.ts", - "typecheck": "tsc -p tsconfig.build.json --noEmit", - "prepublishOnly": "npm run build && npm test" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": ">=18" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - }, - "devDependencies": { - "@types/react": "^19", - "react": "^19.0.0", - "tsx": "^4.22.4", - "typescript": "^5" - } -} diff --git a/packages/weightlift/src/index.ts b/packages/weightlift/src/index.ts deleted file mode 100644 index 53aa3ea..0000000 --- a/packages/weightlift/src/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * weightlift — in-browser ML model manager with download progress. - * - * @packageDocumentation - * - * Runtime-agnostic: Whisper, CLIP, SigLIP, WebLLM, custom ONNX — anything - * that can resolve a promise and optionally report byte progress. - * - * ```ts - * import { ModelManager } from "weightlift"; - * - * const models = new ModelManager(); - * models.define("siglip", { - * load: async ({ progress }) => { - * return pipeline("zero-shot-image-classification", modelId, { - * progress_callback: (p) => { - * if (p.status === "progress_total" && p.total) { - * progress.dispatch({ - * type: "progress_total", - * loaded: ((p.progress ?? 0) / 100) * p.total, - * total: p.total, - * }); - * } - * }, - * }); - * }, - * }); - * - * const clip = await models.load("siglip"); - * ``` - */ - -export { - ModelManager, - type LoadContext, - type ModelDefinition, - type ModelRecord, - type ManagerSnapshot, - type ManagerListener, -} from "./manager.js"; - -export { - Weightlift, - createModelLoader, - createModelRegistry, - type Unsubscribe, -} from "./store.js"; - -export { - reduce, - createReduceContext, - type ReduceContext, -} from "./reduce.js"; - -export { - INITIAL_STATE, - type LoadStatus, - type FileProgress, - type WeightliftState, - type WeightliftEvent, - type WeightliftListener, -} from "./types.js"; diff --git a/packages/weightlift/src/manager.ts b/packages/weightlift/src/manager.ts deleted file mode 100644 index 0cb4836..0000000 --- a/packages/weightlift/src/manager.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { Weightlift, type Unsubscribe } from "./store.js"; -import type { LoadStatus, WeightliftState } from "./types.js"; - -/** Context passed to a model definition's `load` function. */ -export interface LoadContext { - /** Stable model key passed to {@link ModelManager.load}. */ - id: string; - /** - * Per-model progress store. Map your runtime's progress callback into - * `progress.dispatch({ type: "progress_total" | "progress" | … })`. - */ - progress: Weightlift; - /** - * Result of `isCached()` when provided; `null` if not checked. - * Compose UI copy from this in the app — weightlift does not own strings. - */ - fromCache: boolean | null; - /** Optional abort signal if the manager starts supporting cancellation. */ - signal?: AbortSignal; -} - -/** How to materialize (and optionally dispose) one model. */ -export interface ModelDefinition { - /** Fetch / construct the model. Called at most once until unloaded. */ - load: (ctx: LoadContext) => Promise; - /** - * Detect whether weights are already in a browser cache (Cache Storage, - * IndexedDB, etc.). Exposed on {@link ModelRecord.fromCache} so the app - * can choose its own labeling; does not affect load behavior. - */ - isCached?: () => boolean | Promise; - /** Release GPU/WASM resources when {@link ModelManager.unload} is called. */ - dispose?: (value: T) => void | Promise; -} - -/** UI-facing per-model record (no heavyweight model handle). */ -export interface ModelRecord { - id: string; - status: LoadStatus; - /** `true` / `false` after cache check; `null` if not checked or idle. */ - fromCache: boolean | null; - percent: number | null; - indeterminate: boolean; - loadedBytes: number; - totalBytes: number | null; - error: Error | null; - /** Raw progress file map when you need a detailed breakdown. */ - files: WeightliftState["files"]; -} - -/** Aggregated manager snapshot for `useSyncExternalStore`. */ -export interface ManagerSnapshot { - models: Record; - /** Ids currently in `loading`. */ - loading: string[]; - /** Ids in `ready`. */ - ready: string[]; - /** Ids in `error`. */ - errors: string[]; -} - -export type ManagerListener = (snapshot: ManagerSnapshot) => void; - -interface Entry { - id: string; - definition: ModelDefinition; - progress: Weightlift; - fromCache: boolean | null; - promise: Promise | null; - value: T | undefined; - unsubProgress: Unsubscribe; -} - -function recordFrom(entry: Entry): ModelRecord { - const p = entry.progress.getSnapshot(); - return { - id: entry.id, - status: p.status, - fromCache: entry.fromCache, - percent: p.percent, - indeterminate: p.indeterminate, - loadedBytes: p.loadedBytes, - totalBytes: p.totalBytes, - error: p.error, - files: p.files, - }; -} - -function idleRecord(id: string): ModelRecord { - return { - id, - status: "idle", - fromCache: null, - percent: null, - indeterminate: true, - loadedBytes: 0, - totalBytes: null, - error: null, - files: {}, - }; -} - -function buildSnapshot(entries: Map): ManagerSnapshot { - const models: Record = {}; - const loading: string[] = []; - const ready: string[] = []; - const errors: string[] = []; - for (const [id, entry] of entries) { - const rec = recordFrom(entry); - models[id] = rec; - if (rec.status === "loading") loading.push(id); - else if (rec.status === "ready") ready.push(id); - else if (rec.status === "error") errors.push(id); - } - return { models, loading, ready, errors }; -} - -const EMPTY_SNAPSHOT: ManagerSnapshot = { - models: {}, - loading: [], - ready: [], - errors: [], -}; - -/** - * In-browser ML **model manager**: define loaders once, then - * `load` / `get` / `unload` by id with shared progress state. - * - * Runtime-agnostic — `T` can be a Whisper pipeline, CLIP/SigLIP encoder, - * WebLLM engine, or any other handle. weightlift only owns lifecycle + bytes. - * - * ```ts - * const models = new ModelManager(); - * - * models.define("siglip", { - * load: async ({ progress }) => - * pipeline("zero-shot-image-classification", modelId, { - * progress_callback: (p) => { - * // map runtime events → progress.dispatch(...) - * }, - * }), - * }); - * - * const clip = await models.load("siglip"); - * ``` - */ -export class ModelManager { - #entries = new Map(); - #listeners = new Set(); - #snapshot: ManagerSnapshot = EMPTY_SNAPSHOT; - - /** Register (or replace) how a model id is loaded. */ - define(id: string, definition: ModelDefinition): this { - const existing = this.#entries.get(id); - if (existing?.promise && existing.progress.status === "loading") { - throw new Error( - `weightlift: cannot redefine "${id}" while a load is in flight` - ); - } - if (existing) { - existing.unsubProgress(); - void this.#disposeEntry(existing); - } - - const progress = new Weightlift(); - const entry: Entry = { - id, - definition, - progress, - fromCache: null, - promise: null, - value: undefined, - unsubProgress: progress.subscribe(() => this.#emit()), - }; - this.#entries.set(id, entry as Entry); - this.#emit(); - return this; - } - - /** - * Load a model by id. Concurrent callers share one promise. - * Pass `definition` to define-and-load in one shot (lazy registration). - */ - load(id: string, definition?: ModelDefinition): Promise { - if (definition) { - const existing = this.#entries.get(id); - if (!existing) this.define(id, definition); - } - const entry = this.#entries.get(id) as Entry | undefined; - if (!entry) { - return Promise.reject( - new Error( - `weightlift: unknown model "${id}". Call define() or pass a definition to load().` - ) - ); - } - if (entry.value !== undefined && entry.progress.isReady) { - return Promise.resolve(entry.value); - } - if (entry.promise) return entry.promise; - - entry.promise = this.#runLoad(entry); - return entry.promise; - } - - async #runLoad(entry: Entry): Promise { - const { definition, progress, id } = entry; - progress.reset(); - entry.fromCache = null; - - let fromCache: boolean | null = null; - if (definition.isCached) { - try { - fromCache = Boolean(await definition.isCached()); - } catch { - fromCache = null; - } - } - entry.fromCache = fromCache; - progress.start(); - this.#emit(); - - try { - const value = await definition.load({ - id, - progress, - fromCache, - }); - entry.value = value; - progress.ready(); - this.#emit(); - return value; - } catch (err) { - entry.promise = null; - entry.value = undefined; - progress.fail(err); - this.#emit(); - throw err; - } - } - - /** Synchronously return a ready model, or `undefined`. */ - get(id: string): T | undefined { - const entry = this.#entries.get(id); - if (!entry || !entry.progress.isReady) return undefined; - return entry.value as T | undefined; - } - - /** Whether a definition exists for this id. */ - has(id: string): boolean { - return this.#entries.has(id); - } - - isReady(id: string): boolean { - return this.#entries.get(id)?.progress.isReady ?? false; - } - - isLoading(id: string): boolean { - return this.#entries.get(id)?.progress.isLoading ?? false; - } - - /** Per-model UI record (idle stub if unknown). */ - status(id: string): ModelRecord { - const entry = this.#entries.get(id); - return entry ? recordFrom(entry) : idleRecord(id); - } - - /** Underlying progress store for advanced use. */ - progress(id: string): Weightlift | undefined { - return this.#entries.get(id)?.progress; - } - - /** - * Drop a cached instance (and call `dispose` if provided) so the next - * `load()` runs the definition again. Useful after a WebGPU device loss. - */ - async unload(id: string): Promise { - const entry = this.#entries.get(id); - if (!entry) return; - await this.#disposeEntry(entry); - entry.promise = null; - entry.value = undefined; - entry.fromCache = null; - entry.progress.reset(); - this.#emit(); - } - - /** Unload every model and clear definitions. */ - async clear(): Promise { - const ids = [...this.#entries.keys()]; - for (const id of ids) { - const entry = this.#entries.get(id); - if (!entry) continue; - entry.unsubProgress(); - await this.#disposeEntry(entry); - } - this.#entries.clear(); - this.#emit(); - } - - /** Warm multiple models (settles when all have finished or failed). */ - async preload(ids: string[]): Promise { - await Promise.all(ids.map((id) => this.load(id))); - } - - getSnapshot = (): ManagerSnapshot => this.#snapshot; - - getServerSnapshot = (): ManagerSnapshot => EMPTY_SNAPSHOT; - - subscribe = (listener: ManagerListener): Unsubscribe => { - this.#listeners.add(listener); - return () => { - this.#listeners.delete(listener); - }; - }; - - async #disposeEntry(entry: Entry): Promise { - if (entry.value !== undefined && entry.definition.dispose) { - try { - await entry.definition.dispose(entry.value); - } catch { - // dispose errors should not block unload - } - } - } - - #emit(): void { - this.#snapshot = buildSnapshot(this.#entries); - for (const listener of this.#listeners) { - listener(this.#snapshot); - } - } -} - -export { idleRecord }; diff --git a/packages/weightlift/src/react.ts b/packages/weightlift/src/react.ts deleted file mode 100644 index aa9633b..0000000 --- a/packages/weightlift/src/react.ts +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { - ModelManager, - idleRecord, - type ManagerSnapshot, - type ModelRecord, -} from "./manager.js"; -import { Weightlift } from "./store.js"; -import type { WeightliftState } from "./types.js"; - -export type { - WeightliftState, - LoadStatus, - FileProgress, -} from "./types.js"; -export type { - ModelRecord, - ManagerSnapshot, - ModelDefinition, - LoadContext, -} from "./manager.js"; -export { - Weightlift, - createModelLoader, - createModelRegistry, -} from "./store.js"; -export { ModelManager } from "./manager.js"; - -/** Subscribe to the full {@link ModelManager} snapshot. */ -export function useModelManager(manager: ModelManager): ManagerSnapshot { - return useSyncExternalStore( - manager.subscribe, - manager.getSnapshot, - manager.getServerSnapshot - ); -} - -/** - * Subscribe to one model id on a {@link ModelManager}. - * - * Compose UI labels from `status` / `fromCache` in the component — the - * package does not ship copy. - */ -export function useModel( - manager: ModelManager, - id: string -): ModelRecord & { - isLoading: boolean; - isReady: boolean; - isIdle: boolean; - hasError: boolean; - value: T | undefined; - load: () => Promise; - unload: () => Promise; -} { - const snapshot = useModelManager(manager); - const record = snapshot.models[id] ?? idleRecord(id); - - const load = useCallback(() => manager.load(id), [manager, id]); - const unload = useCallback(() => manager.unload(id), [manager, id]); - - return { - ...record, - isLoading: record.status === "loading", - isReady: record.status === "ready", - isIdle: record.status === "idle", - hasError: record.status === "error", - value: manager.get(id), - load, - unload, - }; -} - -/** Stable {@link ModelManager} for the lifetime of the component. */ -export function useModelManagerStore(): ModelManager { - return useMemo(() => new ModelManager(), []); -} - -/** Low-level progress store subscription. Prefer {@link useModel}. */ -export function useWeightlift(weightlift: Weightlift): WeightliftState & { - isLoading: boolean; - isReady: boolean; - isIdle: boolean; - hasError: boolean; -} { - const state = useSyncExternalStore( - weightlift.subscribe, - weightlift.getSnapshot, - weightlift.getServerSnapshot - ); - - return { - ...state, - isLoading: state.status === "loading", - isReady: state.status === "ready", - isIdle: state.status === "idle", - hasError: state.status === "error", - }; -} - -export function useWeightliftStore(key?: string): Weightlift { - return useMemo(() => new Weightlift(), [key]); -} diff --git a/packages/weightlift/src/reduce.ts b/packages/weightlift/src/reduce.ts deleted file mode 100644 index b71a408..0000000 --- a/packages/weightlift/src/reduce.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { - INITIAL_STATE, - type FileProgress, - type WeightliftEvent, - type WeightliftState, -} from "./types.js"; - -interface InternalTotals { - /** Prefer runtime aggregate events when the caller emits them. */ - useTotalEvents: boolean; - /** Highest percent observed; only moves forward except when totals grow. */ - best: number; - lastTotal: number; -} - -export interface ReduceContext { - totals: InternalTotals; -} - -export function createReduceContext(): ReduceContext { - return { - totals: { useTotalEvents: false, best: 0, lastTotal: 0 }, - }; -} - -function cloneFiles( - files: Record -): Record { - const next: Record = {}; - for (const [k, v] of Object.entries(files)) { - next[k] = { ...v }; - } - return next; -} - -function sumFiles(files: Record): { - loadedBytes: number; - totalBytes: number | null; -} { - // Only files that reported a Content-Length contribute to the percentage. - // Files without a total are ignored for the bar — if *no* file has a total, - // the store stays indeterminate. - let loadedBytes = 0; - let totalBytes = 0; - let anyTotal = false; - - for (const f of Object.values(files)) { - if (f.total != null && f.total > 0) { - anyTotal = true; - loadedBytes += Math.min(f.loaded, f.total); - totalBytes += f.total; - } else if (f.status === "done") { - loadedBytes += f.loaded; - totalBytes += f.loaded; - if (f.loaded > 0) anyTotal = true; - } - } - - return { - loadedBytes, - totalBytes: anyTotal ? totalBytes : null, - }; -} - -/** - * Apply a monotonic percent update. When the known byte total grows mid-load - * (common when more files are discovered), rescale `best` so the bar can move - * backwards honestly instead of sticking at an inflated value. - */ -function reportPercent( - ctx: ReduceContext, - loaded: number, - total: number -): { percent: number | null; indeterminate: boolean } { - if (!(total > 0)) { - return { percent: null, indeterminate: true }; - } - const { totals } = ctx; - if (total > totals.lastTotal && totals.lastTotal > 0 && totals.best > 0) { - totals.best *= totals.lastTotal / total; - } - totals.lastTotal = total; - totals.best = Math.max(totals.best, Math.min(1, loaded / total)); - return { percent: totals.best, indeterminate: false }; -} - -function withDerived( - state: WeightliftState, - ctx: ReduceContext, - patch: Partial -): WeightliftState { - const next: WeightliftState = { ...state, ...patch }; - - if (!ctx.totals.useTotalEvents) { - const { loadedBytes, totalBytes } = sumFiles(next.files); - next.loadedBytes = loadedBytes; - next.totalBytes = totalBytes; - if (totalBytes != null && totalBytes > 0) { - const reported = reportPercent(ctx, loadedBytes, totalBytes); - next.percent = reported.percent; - next.indeterminate = reported.indeterminate; - } else { - next.percent = null; - next.indeterminate = true; - } - } - - return next; -} - -/** Pure reducer: previous state + event → next state. */ -export function reduce( - state: WeightliftState, - event: WeightliftEvent, - ctx: ReduceContext -): WeightliftState { - switch (event.type) { - case "reset": - ctx.totals = { useTotalEvents: false, best: 0, lastTotal: 0 }; - return { ...INITIAL_STATE, files: {} }; - - case "start": - return withDerived(state, ctx, { - status: "loading", - error: null, - }); - - case "initiate": { - const files = cloneFiles(state.files); - if (!files[event.file]) { - files[event.file] = { loaded: 0, total: null, status: "pending" }; - } - return withDerived(state, ctx, { - status: state.status === "idle" ? "loading" : state.status, - files, - error: null, - }); - } - - case "progress": { - // Once the runtime is emitting aggregate totals, ignore per-file noise. - if (ctx.totals.useTotalEvents) { - return state; - } - const files = cloneFiles(state.files); - const prev = files[event.file]; - const total = - event.total != null && event.total > 0 - ? event.total - : (prev?.total ?? null); - files[event.file] = { - loaded: event.loaded, - total, - status: "downloading", - }; - return withDerived(state, ctx, { - status: "loading", - files, - error: null, - }); - } - - case "progress_total": { - ctx.totals.useTotalEvents = true; - const reported = reportPercent(ctx, event.loaded, event.total); - return { - ...state, - status: "loading", - loadedBytes: event.loaded, - totalBytes: event.total, - percent: reported.percent, - indeterminate: reported.indeterminate, - error: null, - }; - } - - case "done": { - const files = cloneFiles(state.files); - const prev = files[event.file]; - files[event.file] = { - loaded: prev?.total ?? prev?.loaded ?? 0, - total: prev?.total ?? prev?.loaded ?? null, - status: "done", - }; - return withDerived(state, ctx, { files }); - } - - case "ready": - return { - ...state, - status: "ready", - percent: state.percent ?? 1, - indeterminate: false, - error: null, - }; - - case "error": - return { - ...state, - status: "error", - error: event.error, - }; - - default: - return state; - } -} diff --git a/packages/weightlift/src/store.ts b/packages/weightlift/src/store.ts deleted file mode 100644 index 4252d3a..0000000 --- a/packages/weightlift/src/store.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { reduce, createReduceContext, type ReduceContext } from "./reduce.js"; -import { - INITIAL_STATE, - type WeightliftEvent, - type WeightliftListener, - type WeightliftState, -} from "./types.js"; - -export type Unsubscribe = () => void; - -/** - * Per-model progress store. Framework-agnostic; safe to use in a web worker. - * - * Feed it normalized {@link WeightliftEvent}s from whatever runtime you use - * (Transformers.js `progress_callback`, WebLLM `initProgressCallback`, a - * custom fetch loop, …). The {@link ModelManager} owns one of these per id. - */ -export class Weightlift { - #state: WeightliftState = { ...INITIAL_STATE, files: {} }; - #listeners = new Set(); - #ctx: ReduceContext = createReduceContext(); - - /** Current snapshot (referentially stable until the next event). */ - getSnapshot = (): WeightliftState => this.#state; - - /** SSR / server snapshot — always the idle initial state. */ - getServerSnapshot = (): WeightliftState => INITIAL_STATE; - - /** - * Subscribe to state changes. Returns an unsubscribe function. - * Compatible with `useSyncExternalStore`. - */ - subscribe = (listener: WeightliftListener): Unsubscribe => { - this.#listeners.add(listener); - return () => { - this.#listeners.delete(listener); - }; - }; - - /** Dispatch a normalized progress event. */ - dispatch(event: WeightliftEvent): void { - const next = reduce(this.#state, event, this.#ctx); - if (next === this.#state) return; - this.#state = next; - for (const listener of this.#listeners) { - listener(next); - } - } - - /** Mark loading started. */ - start(): void { - this.dispatch({ type: "start" }); - } - - /** Mark the model ready. */ - ready(): void { - this.dispatch({ type: "ready" }); - } - - /** Record a failure. */ - fail(error: unknown): void { - const err = error instanceof Error ? error : new Error(String(error)); - this.dispatch({ type: "error", error: err }); - } - - /** Reset to idle (e.g. before loading a different model). */ - reset(): void { - this.dispatch({ type: "reset" }); - } - - get status(): WeightliftState["status"] { - return this.#state.status; - } - - get isLoading(): boolean { - return this.#state.status === "loading"; - } - - get isReady(): boolean { - return this.#state.status === "ready"; - } - - get isIdle(): boolean { - return this.#state.status === "idle"; - } - - get hasError(): boolean { - return this.#state.status === "error"; - } -} - -/** - * Deduplicating loader for a single model. Prefer {@link ModelManager} when - * you manage more than one id. - */ -export function createModelLoader(loadFn: (wl: Weightlift) => Promise): { - weightlift: Weightlift; - load: () => Promise; - invalidate: () => void; -} { - const weightlift = new Weightlift(); - let promise: Promise | null = null; - - return { - weightlift, - load: () => { - if (!promise) { - weightlift.reset(); - weightlift.start(); - promise = loadFn(weightlift).then( - (value) => { - weightlift.ready(); - return value; - }, - (err) => { - weightlift.fail(err); - promise = null; - throw err; - } - ); - } - return promise; - }, - invalidate: () => { - promise = null; - }, - }; -} - -/** - * Multi-model registry of {@link createModelLoader}s. - * Prefer {@link ModelManager} for the full define/load/unload API. - */ -export function createModelRegistry() { - const loaders = new Map< - string, - ReturnType> - >(); - - return { - get(key: string, loadFn: (wl: Weightlift) => Promise) { - let loader = loaders.get(key); - if (!loader) { - loader = createModelLoader(loadFn); - loaders.set(key, loader); - } - return loader; - }, - has(key: string): boolean { - return loaders.has(key); - }, - invalidate(key: string): void { - loaders.get(key)?.invalidate(); - }, - clear(): void { - loaders.clear(); - }, - }; -} diff --git a/packages/weightlift/src/types.ts b/packages/weightlift/src/types.ts deleted file mode 100644 index daa1872..0000000 --- a/packages/weightlift/src/types.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** High-level load lifecycle. */ -export type LoadStatus = "idle" | "loading" | "ready" | "error"; - -/** Per-file download / cache-read progress. */ -export interface FileProgress { - loaded: number; - /** `null` when the server omitted Content-Length. */ - total: number | null; - status: "pending" | "downloading" | "done"; -} - -/** - * Aggregated model-load progress. - * - * `percent` is `null` (and `indeterminate` is true) when no usable byte total - * is known yet — files can download in parallel and some servers omit - * Content-Length, so a single 0..1 bar is not always available. - * - * UI copy ("Downloading…") is intentionally not part of this state — compose - * labels in the app from `status` / `fromCache` on {@link ModelRecord}. - */ -export interface WeightliftState { - status: LoadStatus; - files: Record; - loadedBytes: number; - /** Sum of known file totals; `null` if none reported a total. */ - totalBytes: number | null; - /** 0..1 progress, or `null` when indeterminate. */ - percent: number | null; - indeterminate: boolean; - error: Error | null; -} - -/** - * Normalized progress events. Map your runtime's callback into these and - * `dispatch` them — Transformers.js, WebLLM, whisper.cpp, custom fetch, etc. - */ -export type WeightliftEvent = - | { type: "start" } - | { type: "initiate"; file: string } - | { - type: "progress"; - file: string; - loaded: number; - /** Omit or pass `undefined` when Content-Length is missing. */ - total?: number; - } - /** - * Runtime-provided aggregate (e.g. transformers.js `progress_total`). - * Preferred over summing per-file events when available — totals are often - * pre-seeded so the bar does not jump to 100% after the first file finishes. - */ - | { type: "progress_total"; loaded: number; total: number } - | { type: "done"; file: string } - | { type: "ready" } - | { type: "error"; error: Error } - | { type: "reset" }; - -export type WeightliftListener = (state: WeightliftState) => void; - -export const INITIAL_STATE: WeightliftState = { - status: "idle", - files: {}, - loadedBytes: 0, - totalBytes: null, - percent: null, - indeterminate: true, - error: null, -}; diff --git a/packages/weightlift/src/worker.ts b/packages/weightlift/src/worker.ts deleted file mode 100644 index f72b57a..0000000 --- a/packages/weightlift/src/worker.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Weightlift } from "./store.js"; -import type { WeightliftEvent, WeightliftState } from "./types.js"; - -/** Discriminator so host apps can multiplex other worker messages safely. */ -export const WEIGHTLIFT_MESSAGE_TYPE = "weightlift" as const; - -export type WeightliftWorkerMessage = - | { type: typeof WEIGHTLIFT_MESSAGE_TYPE; kind: "event"; event: WeightliftEvent } - | { - type: typeof WEIGHTLIFT_MESSAGE_TYPE; - kind: "state"; - state: WeightliftState; - }; - -/** Minimal sink that accepts normalized events (store or reporter). */ -export interface EventSink { - dispatch(event: WeightliftEvent): void; -} - -function isWeightliftMessage(data: unknown): data is WeightliftWorkerMessage { - return ( - typeof data === "object" && - data !== null && - (data as { type?: unknown }).type === WEIGHTLIFT_MESSAGE_TYPE - ); -} - -export interface PostMessageTarget { - postMessage(message: unknown, transfer?: Transferable[]): void; -} - -export interface WorkerReporter { - /** Dispatch into the local store and forward across the worker boundary. */ - dispatch(event: WeightliftEvent): void; - /** Stop forwarding (store continues to work locally). */ - unsubscribe: () => void; -} - -/** - * Worker-side helper: every update is forwarded to the main thread as a - * serializable {@link WeightliftWorkerMessage}. - * - * Default `mode: "state"` posts snapshots. `mode: "event"` forwards raw - * events so the main thread can rehydrate via {@link attachWorker}. - */ -export function createWorkerReporter( - post: PostMessageTarget | ((message: unknown) => void), - weightlift: Weightlift, - options?: { mode?: "state" | "event" } -): WorkerReporter { - const mode = options?.mode ?? "state"; - const postMessage = - typeof post === "function" ? post : (msg: unknown) => post.postMessage(msg); - - if (mode === "event") { - return { - dispatch(event: WeightliftEvent) { - weightlift.dispatch(event); - const msg: WeightliftWorkerMessage = { - type: WEIGHTLIFT_MESSAGE_TYPE, - kind: "event", - event, - }; - postMessage(msg); - }, - unsubscribe: () => { - /* nothing to detach in event mode */ - }, - }; - } - - const unsubscribe = weightlift.subscribe((state) => { - const msg: WeightliftWorkerMessage = { - type: WEIGHTLIFT_MESSAGE_TYPE, - kind: "state", - state, - }; - postMessage(msg); - }); - - return { - dispatch(event: WeightliftEvent) { - weightlift.dispatch(event); - }, - unsubscribe, - }; -} - -/** - * Main-thread helper: listen for weightlift messages from a worker and apply - * them onto a local {@link Weightlift} store. - */ -export function attachWorker( - worker: { - addEventListener: ( - type: "message", - listener: (ev: MessageEvent) => void - ) => void; - removeEventListener: ( - type: "message", - listener: (ev: MessageEvent) => void - ) => void; - }, - weightlift: Weightlift, - options?: { - onState?: (state: WeightliftState) => void; - } -): () => void { - const onMessage = (ev: MessageEvent) => { - if (!isWeightliftMessage(ev.data)) return; - if (ev.data.kind === "event") { - weightlift.dispatch(ev.data.event); - return; - } - options?.onState?.(ev.data.state); - mirrorState(weightlift, ev.data.state); - }; - - worker.addEventListener("message", onMessage); - return () => worker.removeEventListener("message", onMessage); -} - -/** Best-effort projection of a remote snapshot onto a local store. */ -function mirrorState(weightlift: Weightlift, state: WeightliftState): void { - weightlift.reset(); - if (state.status === "idle") return; - - weightlift.start(); - - if (state.totalBytes != null && state.totalBytes > 0) { - weightlift.dispatch({ - type: "progress_total", - loaded: state.loadedBytes, - total: state.totalBytes, - }); - } else { - for (const [file, fp] of Object.entries(state.files)) { - weightlift.dispatch({ type: "initiate", file }); - weightlift.dispatch({ - type: "progress", - file, - loaded: fp.loaded, - total: fp.total ?? undefined, - }); - if (fp.status === "done") { - weightlift.dispatch({ type: "done", file }); - } - } - } - - if (state.status === "ready") weightlift.ready(); - if (state.status === "error") { - weightlift.fail(state.error ?? new Error("load failed")); - } -} - -export { isWeightliftMessage }; diff --git a/packages/weightlift/tests/manager-test.ts b/packages/weightlift/tests/manager-test.ts deleted file mode 100644 index 4d05c86..0000000 --- a/packages/weightlift/tests/manager-test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { ModelManager } from "../src/manager.js"; -import type { Weightlift } from "../src/store.js"; - -function deferred() { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe("ModelManager", () => { - it("define + load returns the model and marks ready", async () => { - const models = new ModelManager(); - models.define("clip", { - load: async () => ({ name: "clip" }), - }); - const value = await models.load<{ name: string }>("clip"); - assert.deepEqual(value, { name: "clip" }); - assert.equal(models.isReady("clip"), true); - assert.deepEqual(models.get("clip"), { name: "clip" }); - assert.equal(models.status("clip").status, "ready"); - }); - - it("dedupes concurrent load() calls", async () => { - let calls = 0; - const gate = deferred(); - const models = new ModelManager(); - models.define("m", { - load: async () => { - calls += 1; - await gate.promise; - return "ok"; - }, - }); - const p1 = models.load("m"); - const p2 = models.load("m"); - assert.equal(models.isLoading("m"), true); - gate.resolve(); - assert.equal(await p1, "ok"); - assert.equal(await p2, "ok"); - assert.equal(calls, 1); - }); - - it("load(id, definition) registers lazily", async () => { - const models = new ModelManager(); - const value = await models.load("lazy", { - load: async () => 42, - }); - assert.equal(value, 42); - assert.equal(models.has("lazy"), true); - }); - - it("exposes fromCache without owning UI copy", async () => { - const models = new ModelManager(); - await models.load("w", { - isCached: async () => true, - load: async () => "x", - }); - assert.equal(models.status("w").fromCache, true); - - const models2 = new ModelManager(); - await models2.load("w", { - isCached: async () => false, - load: async () => "x", - }); - assert.equal(models2.status("w").fromCache, false); - }); - - it("exposes progress through the load context", async () => { - const models = new ModelManager(); - let progress: Weightlift | undefined; - const gate = deferred(); - const done = models.load("p", { - load: async (ctx) => { - progress = ctx.progress; - ctx.progress.dispatch({ - type: "progress_total", - loaded: 25, - total: 100, - }); - await gate.promise; - return "model"; - }, - }); - await Promise.resolve(); - await Promise.resolve(); - assert.ok(progress); - assert.equal(models.status("p").percent, 0.25); - assert.equal(models.getSnapshot().loading.includes("p"), true); - gate.resolve(); - await done; - assert.equal(models.getSnapshot().ready.includes("p"), true); - }); - - it("unload clears the instance so load runs again", async () => { - let calls = 0; - const models = new ModelManager(); - models.define("x", { - load: async () => { - calls += 1; - return calls; - }, - }); - assert.equal(await models.load("x"), 1); - await models.unload("x"); - assert.equal(models.get("x"), undefined); - assert.equal(models.isReady("x"), false); - assert.equal(await models.load("x"), 2); - }); - - it("calls dispose on unload", async () => { - let disposed: string | null = null; - const models = new ModelManager(); - models.define("gpu", { - load: async () => "session", - dispose: async (v) => { - disposed = v; - }, - }); - await models.load("gpu"); - await models.unload("gpu"); - assert.equal(disposed, "session"); - }); - - it("retries after failure", async () => { - let calls = 0; - const models = new ModelManager(); - models.define("flaky", { - load: async () => { - calls += 1; - if (calls === 1) throw new Error("nope"); - return "ok"; - }, - }); - await assert.rejects(() => models.load("flaky")); - assert.equal(models.status("flaky").status, "error"); - assert.equal(await models.load("flaky"), "ok"); - assert.equal(calls, 2); - }); - - it("preload loads many ids", async () => { - const models = new ModelManager(); - models.define("a", { load: async () => "a" }); - models.define("b", { load: async () => "b" }); - await models.preload(["a", "b"]); - assert.deepEqual(models.getSnapshot().ready.sort(), ["a", "b"]); - }); - - it("rejects load of unknown id", async () => { - const models = new ModelManager(); - await assert.rejects(() => models.load("missing"), /unknown model/); - }); -}); diff --git a/packages/weightlift/tests/store-test.ts b/packages/weightlift/tests/store-test.ts deleted file mode 100644 index 8b3af11..0000000 --- a/packages/weightlift/tests/store-test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - Weightlift, - createModelLoader, - createModelRegistry, -} from "../src/store.js"; - -describe("Weightlift", () => { - it("starts idle and becomes loading on start()", () => { - const wl = new Weightlift(); - assert.equal(wl.status, "idle"); - assert.equal(wl.isIdle, true); - wl.start(); - assert.equal(wl.isLoading, true); - }); - - it("aggregates parallel per-file progress into one percent", () => { - const wl = new Weightlift(); - wl.start(); - wl.dispatch({ type: "progress", file: "a.onnx", loaded: 50, total: 100 }); - wl.dispatch({ type: "progress", file: "b.onnx", loaded: 0, total: 100 }); - const s = wl.getSnapshot(); - assert.equal(s.loadedBytes, 50); - assert.equal(s.totalBytes, 200); - assert.equal(s.percent, 0.25); - assert.equal(s.indeterminate, false); - }); - - it("marks indeterminate when a file has no total", () => { - const wl = new Weightlift(); - wl.start(); - wl.dispatch({ type: "progress", file: "a.onnx", loaded: 50 }); - const s = wl.getSnapshot(); - assert.equal(s.indeterminate, true); - assert.equal(s.percent, null); - }); - - it("prefers progress_total over per-file events", () => { - const wl = new Weightlift(); - wl.start(); - wl.dispatch({ type: "progress_total", loaded: 10, total: 100 }); - wl.dispatch({ type: "progress", file: "a.onnx", loaded: 100, total: 100 }); - const s = wl.getSnapshot(); - assert.equal(s.percent, 0.1); - assert.equal(s.loadedBytes, 10); - }); - - it("rescales best when total grows", () => { - const wl = new Weightlift(); - wl.start(); - wl.dispatch({ type: "progress_total", loaded: 50, total: 100 }); - assert.equal(wl.getSnapshot().percent, 0.5); - wl.dispatch({ type: "progress_total", loaded: 50, total: 200 }); - assert.ok(wl.getSnapshot().percent !== null); - assert.ok((wl.getSnapshot().percent as number) <= 0.5); - assert.ok(Math.abs((wl.getSnapshot().percent as number) - 0.25) < 1e-9); - }); - - it("notifies subscribers and unsubscribes cleanly", () => { - const wl = new Weightlift(); - const seen: number[] = []; - const unsub = wl.subscribe((s) => { - if (s.percent != null) seen.push(s.percent); - }); - wl.dispatch({ type: "progress_total", loaded: 1, total: 10 }); - unsub(); - wl.dispatch({ type: "progress_total", loaded: 5, total: 10 }); - assert.deepEqual(seen, [0.1]); - }); - - it("ready / fail / reset lifecycle", () => { - const wl = new Weightlift(); - wl.start(); - wl.ready(); - assert.equal(wl.isReady, true); - assert.equal(wl.getSnapshot().percent, 1); - - wl.fail(new Error("boom")); - assert.equal(wl.hasError, true); - assert.equal(wl.getSnapshot().error?.message, "boom"); - - wl.reset(); - assert.equal(wl.isIdle, true); - assert.equal(wl.getSnapshot().error, null); - }); -}); - -describe("createModelLoader", () => { - it("dedupes concurrent load() calls", async () => { - let calls = 0; - const loader = createModelLoader(async (wl) => { - calls += 1; - wl.dispatch({ type: "progress_total", loaded: 1, total: 1 }); - return "model"; - }); - const [a, b] = await Promise.all([loader.load(), loader.load()]); - assert.equal(a, "model"); - assert.equal(b, "model"); - assert.equal(calls, 1); - assert.equal(loader.weightlift.isReady, true); - }); - - it("retries after failure when invalidated", async () => { - let calls = 0; - const loader = createModelLoader(async () => { - calls += 1; - if (calls === 1) throw new Error("nope"); - return "ok"; - }); - await assert.rejects(() => loader.load()); - assert.equal(loader.weightlift.hasError, true); - const value = await loader.load(); - assert.equal(value, "ok"); - assert.equal(calls, 2); - }); -}); - -describe("createModelRegistry", () => { - it("keeps separate loaders per key", async () => { - const registry = createModelRegistry(); - const a = registry.get("base", async () => "base"); - const b = registry.get("small", async () => "small"); - assert.notEqual(a, b); - assert.equal(await a.load(), "base"); - assert.equal(await b.load(), "small"); - }); -}); diff --git a/packages/weightlift/tests/worker-test.ts b/packages/weightlift/tests/worker-test.ts deleted file mode 100644 index 6b15f71..0000000 --- a/packages/weightlift/tests/worker-test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { Weightlift } from "../src/store.js"; -import { - WEIGHTLIFT_MESSAGE_TYPE, - attachWorker, - createWorkerReporter, - isWeightliftMessage, -} from "../src/worker.js"; - -describe("worker bridge", () => { - it("forwards state snapshots over postMessage", () => { - const wl = new Weightlift(); - const posted: unknown[] = []; - const reporter = createWorkerReporter((msg) => posted.push(msg), wl); - - wl.start(); - wl.dispatch({ type: "progress_total", loaded: 25, total: 100 }); - - assert.ok(posted.length >= 2); - const last = posted[posted.length - 1]; - assert.equal(isWeightliftMessage(last), true); - if (isWeightliftMessage(last) && last.kind === "state") { - assert.equal(last.state.percent, 0.25); - assert.equal(last.state.status, "loading"); - } - reporter.unsubscribe(); - }); - - it("forwards raw events in event mode", () => { - const wl = new Weightlift(); - const posted: unknown[] = []; - const reporter = createWorkerReporter((msg) => posted.push(msg), wl, { - mode: "event", - }); - - reporter.dispatch({ type: "start" }); - reporter.dispatch({ type: "progress_total", loaded: 1, total: 2 }); - - assert.equal(posted.length, 2); - assert.deepEqual(posted[0], { - type: WEIGHTLIFT_MESSAGE_TYPE, - kind: "event", - event: { type: "start" }, - }); - assert.equal(wl.getSnapshot().percent, 0.5); - }); - - it("attachWorker rehydrates events on the main thread", () => { - const main = new Weightlift(); - const listeners = new Set<(ev: MessageEvent) => void>(); - const worker = { - addEventListener(_type: "message", listener: (ev: MessageEvent) => void) { - listeners.add(listener); - }, - removeEventListener( - _type: "message", - listener: (ev: MessageEvent) => void - ) { - listeners.delete(listener); - }, - emit(data: unknown) { - for (const l of listeners) l({ data } as MessageEvent); - }, - }; - - const stop = attachWorker(worker, main); - worker.emit({ - type: WEIGHTLIFT_MESSAGE_TYPE, - kind: "event", - event: { type: "start" }, - }); - worker.emit({ - type: WEIGHTLIFT_MESSAGE_TYPE, - kind: "event", - event: { type: "progress_total", loaded: 3, total: 4 }, - }); - - assert.equal(main.getSnapshot().status, "loading"); - assert.equal(main.getSnapshot().percent, 0.75); - stop(); - assert.equal(listeners.size, 0); - }); -}); diff --git a/packages/weightlift/tsconfig.build.json b/packages/weightlift/tsconfig.build.json deleted file mode 100644 index 4b1ba4c..0000000 --- a/packages/weightlift/tsconfig.build.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["tests", "dist"] -} diff --git a/packages/weightlift/tsconfig.json b/packages/weightlift/tsconfig.json deleted file mode 100644 index 0bd25a9..0000000 --- a/packages/weightlift/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM"], - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "esModuleInterop": true, - "isolatedModules": true, - "declaration": true, - "jsx": "react-jsx", - "rootDir": "." - }, - "include": ["src/**/*.ts", "tests/**/*.ts"] -} From 98bed6db6c63ab0872e86ec465882f585b746845 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 00:46:02 +0000 Subject: [PATCH 06/11] Clarify WebGPU fallback clears the whole ASR cache A lost GPU device invalidates every WebGPU session, so unload all cached ASR models (equivalent to asrPromises.clear()), not only the current choice. Co-authored-by: Wassim Gharbi --- workers/transcription.worker.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 2c81685..f4ace53 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -181,11 +181,17 @@ async function getAsr(choice: WhisperModel) { }); } -/** Drop the dead WebGPU pipeline and reload the same model on WASM. */ +/** + * Drop dead WebGPU pipelines and reload the requested model on WASM. + * A lost GPU device invalidates every WebGPU session, so clear the whole + * ASR cache (same as the old asrPromises.clear()) — not just `choice`. + */ async function fallbackAsrToWasm(choice: WhisperModel) { forceWasm = true; asrDevice = "wasm"; - await Promise.all(Object.values(MODELS).map((m) => asrModels.unload(m.id))); + await Promise.all( + Object.keys(asrModels.getSnapshot().models).map((id) => asrModels.unload(id)) + ); post({ type: "progress", message: "GPU interrupted — continuing on CPU…", From 6f9aeb946b749669021f2a334b361f8687f3571c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 00:52:33 +0000 Subject: [PATCH 07/11] Register Whisper models up front; load only by id Switch the transcription worker to weightlift's registry-first API (seed ModelManager with all Whisper sizes, then load(id)). Depends on weightlift@^0.2.0. Co-authored-by: Wassim Gharbi --- package-lock.json | 8 ++-- package.json | 2 +- workers/transcription.worker.ts | 66 +++++++++++++++++++-------------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98fb6b4..15006be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "^0.1.0", + "weightlift": "^0.2.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -10684,9 +10684,9 @@ } }, "node_modules/weightlift": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.1.0.tgz", - "integrity": "sha512-PfDEDUZXrNwJdtWz3S5IKene+/tQhqNYAL+cN7TQMJHgYRP6exV9Hb3tbCO5//SQwG4qA7ZqFc/pOvyjd0MPkQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.0.tgz", + "integrity": "sha512-yVOnT4cMuQ7WGx/3uv76Q7QyLQ3MtONjtnCR+/e7A9e4beN7xHwUrGPv+Jq3swSLZHLXiUbqCcEXksReeGElNQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index b0795f8..a4626ab 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "^0.1.0", + "weightlift": "^0.2.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index f4ace53..7f3c69d 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,7 +20,7 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; -import { ModelManager, type Weightlift } from "weightlift"; +import { ModelManager, type LoadContext, type Weightlift } from "weightlift"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; import { cleanTranscript } from "@/lib/hallucinations"; @@ -129,32 +129,12 @@ function feedTransformersProgress(progress: Weightlift) { }; } -/** - * Singleton ASR pipelines + download progress, keyed by Hugging Face model id. - * Concurrent getAsr() calls share one promise; unload() after a WebGPU loss - * forces a clean reload on WASM. - */ -const asrModels = new ModelManager(); -asrModels.subscribe((snap) => { - const id = snap.loading[0]; - if (!id) return; - const rec = snap.models[id]; - if (!rec) return; - post({ - type: "progress", - message: - rec.fromCache === true - ? "Loading speech model from cache…" - : "Downloading speech model…", - value: rec.indeterminate ? null : rec.percent, - }); -}); - -async function getAsr(choice: WhisperModel) { +/** Build a Weightlift definition for one Whisper size (registered once below). */ +function asrDefinition(choice: WhisperModel) { const { id, dtype } = MODELS[choice]; - return asrModels.load(id, { + return { isCached: () => isModelCached(id), - load: async ({ progress }) => { + load: async ({ progress }: LoadContext) => { const device = await pickDevice(); asrDevice = device; const onProgress = feedTransformersProgress(progress); @@ -178,7 +158,39 @@ async function getAsr(choice: WhisperModel) { throw err; } }, + }; +} + +/** + * ASR registry keyed by Hugging Face model id. Definitions are registered up + * front; getAsr() only loads by id. unloadAll() after a WebGPU loss forces a + * clean reload on WASM. + */ +const asrModels = new ModelManager({ + models: Object.fromEntries( + (Object.keys(MODELS) as WhisperModel[]).map((choice) => [ + MODELS[choice].id, + asrDefinition(choice), + ]) + ), +}); +asrModels.subscribe((snap) => { + const id = snap.loading[0]; + if (!id) return; + const rec = snap.models[id]; + if (!rec) return; + post({ + type: "progress", + message: + rec.fromCache === true + ? "Loading speech model from cache…" + : "Downloading speech model…", + value: rec.indeterminate ? null : rec.percent, }); +}); + +async function getAsr(choice: WhisperModel) { + return asrModels.load(MODELS[choice].id); } /** @@ -189,9 +201,7 @@ async function getAsr(choice: WhisperModel) { async function fallbackAsrToWasm(choice: WhisperModel) { forceWasm = true; asrDevice = "wasm"; - await Promise.all( - Object.keys(asrModels.getSnapshot().models).map((id) => asrModels.unload(id)) - ); + await asrModels.unloadAll(); post({ type: "progress", message: "GPU interrupted — continuing on CPU…", From 888dafbe73203bc489d811db2861fedca78d1cf2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 00:59:40 +0000 Subject: [PATCH 08/11] Use weightlift/transformers for progress and cache checks Drop the local transformers.js progress/cache helpers from the worker in favor of the optional weightlift/transformers entry. Co-authored-by: Wassim Gharbi --- package-lock.json | 2 +- workers/transcription.worker.ts | 66 +++++---------------------------- 2 files changed, 11 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15006be..610271f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10686,7 +10686,7 @@ "node_modules/weightlift": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.0.tgz", - "integrity": "sha512-yVOnT4cMuQ7WGx/3uv76Q7QyLQ3MtONjtnCR+/e7A9e4beN7xHwUrGPv+Jq3swSLZHLXiUbqCcEXksReeGElNQ==", + "integrity": "sha512-GXcQYxAVYoPdn9yACJPmE5xnscsp1+SmVN3HE4K4+z3MTJ+HryRkg4ZZoUM4sBzpw/NJ04gLEhZgN00Mr3BSxg==", "license": "MIT", "engines": { "node": ">=18" diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 7f3c69d..4e5885a 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,7 +20,11 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; -import { ModelManager, type LoadContext, type Weightlift } from "weightlift"; +import { ModelManager, type LoadContext } from "weightlift"; +import { + isTransformersModelCached, + transformersProgress, +} from "weightlift/transformers"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; import { cleanTranscript } from "@/lib/hallucinations"; @@ -58,22 +62,6 @@ type AsrChunk = { text: string; timestamp: [number, number | null] }; const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); -/** - * Whether a model's weights are already in the transformers.js browser cache. - * Used purely to label the progress UI accurately ("Loading … from cache" - * instead of "Downloading …"): transformers.js emits identical progress - * events when reading a cached model from disk as when downloading it. - */ -async function isModelCached(modelId: string): Promise { - try { - const cache = await caches.open(env.cacheKey ?? "transformers-cache"); - const keys = await cache.keys(); - return keys.some((req) => req.url.includes(modelId) && req.url.includes(".onnx")); - } catch { - return false; - } -} - /** Once set, this worker stays on WASM — a lost WebGPU session cannot be reused. */ let forceWasm = false; /** Device the current ASR pipeline is running on. */ @@ -92,52 +80,18 @@ async function pickDevice(): Promise<"webgpu" | "wasm"> { return "wasm"; } -/** - * Map transformers.js progress_callback events into a Weightlift store. - * Kept local — weightlift stays runtime-agnostic. - */ -function feedTransformersProgress(progress: Weightlift) { - return (p: { - status?: string; - file?: string; - loaded?: number; - total?: number; - progress?: number; - }) => { - if (p.status === "progress_total") { - const total = p.total ?? 0; - if (!(total > 0)) return; - const loaded = - typeof p.progress === "number" - ? (p.progress / 100) * total - : (p.loaded ?? 0); - progress.dispatch({ type: "progress_total", loaded, total }); - return; - } - if (p.status === "progress" && p.file) { - progress.dispatch({ - type: "progress", - file: p.file, - loaded: p.loaded ?? 0, - total: p.total, - }); - return; - } - if (p.status === "done" && p.file) { - progress.dispatch({ type: "done", file: p.file }); - } - }; -} - /** Build a Weightlift definition for one Whisper size (registered once below). */ function asrDefinition(choice: WhisperModel) { const { id, dtype } = MODELS[choice]; return { - isCached: () => isModelCached(id), + isCached: () => + isTransformersModelCached(id, { + cacheKey: env.cacheKey ?? "transformers-cache", + }), load: async ({ progress }: LoadContext) => { const device = await pickDevice(); asrDevice = device; - const onProgress = feedTransformersProgress(progress); + const onProgress = transformersProgress(progress); try { return (await pipeline("automatic-speech-recognition", id, { dtype: dtype[device], From 6e56562b6a1e48d4d6e671a03e7b4a17b6aa6ad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 01:03:36 +0000 Subject: [PATCH 09/11] Use defineTransformersModel for Whisper pipeline loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register ASR models with weightlift/transformers helpers that own device pick, progress, cache checks, and WebGPU→WASM retry. Co-authored-by: Wassim Gharbi --- package-lock.json | 2 +- workers/transcription.worker.ts | 81 ++++++++++----------------------- 2 files changed, 24 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 610271f..b3e2419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10686,7 +10686,7 @@ "node_modules/weightlift": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.0.tgz", - "integrity": "sha512-GXcQYxAVYoPdn9yACJPmE5xnscsp1+SmVN3HE4K4+z3MTJ+HryRkg4ZZoUM4sBzpw/NJ04gLEhZgN00Mr3BSxg==", + "integrity": "sha512-On5PCBMaA0QXvDY17JnZgDZofTU2XsxpYEGSuRCBtvh3tKd/eBy/FfO5xBNBQEUCr/LaJRGGpU2EZtmm7TnEMQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 4e5885a..7cb640d 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -20,10 +20,10 @@ import { env, type AutomaticSpeechRecognitionPipeline, } from "@huggingface/transformers"; -import { ModelManager, type LoadContext } from "weightlift"; +import { ModelManager } from "weightlift"; import { - isTransformersModelCached, - transformersProgress, + createTransformersDevicePolicy, + defineTransformersModel, } from "weightlift/transformers"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; @@ -62,59 +62,11 @@ type AsrChunk = { text: string; timestamp: [number, number | null] }; const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); -/** Once set, this worker stays on WASM — a lost WebGPU session cannot be reused. */ -let forceWasm = false; +/** Shared across Whisper sizes so one WebGPU failure sticks to WASM. */ +const asrDevices = createTransformersDevicePolicy(); /** Device the current ASR pipeline is running on. */ let asrDevice: "webgpu" | "wasm" = "wasm"; -async function pickDevice(): Promise<"webgpu" | "wasm"> { - if (forceWasm) return "wasm"; - try { - const gpu = (globalThis.navigator as Navigator & { - gpu?: { requestAdapter: () => Promise }; - })?.gpu; - if (gpu && (await gpu.requestAdapter())) return "webgpu"; - } catch { - // fall through to wasm - } - return "wasm"; -} - -/** Build a Weightlift definition for one Whisper size (registered once below). */ -function asrDefinition(choice: WhisperModel) { - const { id, dtype } = MODELS[choice]; - return { - isCached: () => - isTransformersModelCached(id, { - cacheKey: env.cacheKey ?? "transformers-cache", - }), - load: async ({ progress }: LoadContext) => { - const device = await pickDevice(); - asrDevice = device; - const onProgress = transformersProgress(progress); - try { - return (await pipeline("automatic-speech-recognition", id, { - dtype: dtype[device], - device, - progress_callback: onProgress, - })) as AutomaticSpeechRecognitionPipeline; - } catch (err) { - // WebGPU can fail on some drivers; retry once on plain WASM. - if (device === "webgpu") { - forceWasm = true; - asrDevice = "wasm"; - return (await pipeline("automatic-speech-recognition", id, { - dtype: dtype.wasm, - device: "wasm", - progress_callback: onProgress, - })) as AutomaticSpeechRecognitionPipeline; - } - throw err; - } - }, - }; -} - /** * ASR registry keyed by Hugging Face model id. Definitions are registered up * front; getAsr() only loads by id. unloadAll() after a WebGPU loss forces a @@ -122,10 +74,23 @@ function asrDefinition(choice: WhisperModel) { */ const asrModels = new ModelManager({ models: Object.fromEntries( - (Object.keys(MODELS) as WhisperModel[]).map((choice) => [ - MODELS[choice].id, - asrDefinition(choice), - ]) + (Object.keys(MODELS) as WhisperModel[]).map((choice) => { + const { id, dtype } = MODELS[choice]; + return [ + id, + defineTransformersModel({ + pipeline, + task: "automatic-speech-recognition", + modelId: id, + dtype, + devicePolicy: asrDevices, + cacheKey: env.cacheKey ?? "transformers-cache", + onDevice: (device) => { + asrDevice = device; + }, + }), + ]; + }) ), }); asrModels.subscribe((snap) => { @@ -153,7 +118,7 @@ async function getAsr(choice: WhisperModel) { * ASR cache (same as the old asrPromises.clear()) — not just `choice`. */ async function fallbackAsrToWasm(choice: WhisperModel) { - forceWasm = true; + asrDevices.preferWasm(); asrDevice = "wasm"; await asrModels.unloadAll(); post({ From 3a1ced343b90c102f649e686b09d85817141d93e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 01:08:07 +0000 Subject: [PATCH 10/11] Use transformersModel with default fallbackDevicePolicy Drop per-worker device policy construction; preferWasm goes through the shared fallbackDevicePolicy export. Co-authored-by: Wassim Gharbi --- package-lock.json | 2 +- workers/transcription.worker.ts | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3e2419..12f36ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10686,7 +10686,7 @@ "node_modules/weightlift": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.0.tgz", - "integrity": "sha512-On5PCBMaA0QXvDY17JnZgDZofTU2XsxpYEGSuRCBtvh3tKd/eBy/FfO5xBNBQEUCr/LaJRGGpU2EZtmm7TnEMQ==", + "integrity": "sha512-W0NCTaZW1CXL+bgkHjukHajnsQOgVvrD2sULvK65l/LljFi5rLDiFXKW51Bc5Xoqlqi5jGgE9BkC6N4PwLNrPw==", "license": "MIT", "engines": { "node": ">=18" diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 7cb640d..bd9c258 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -22,8 +22,8 @@ import { } from "@huggingface/transformers"; import { ModelManager } from "weightlift"; import { - createTransformersDevicePolicy, - defineTransformersModel, + fallbackDevicePolicy, + transformersModel, } from "weightlift/transformers"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, type WhisperModel } from "@/lib/models"; @@ -62,8 +62,6 @@ type AsrChunk = { text: string; timestamp: [number, number | null] }; const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); -/** Shared across Whisper sizes so one WebGPU failure sticks to WASM. */ -const asrDevices = createTransformersDevicePolicy(); /** Device the current ASR pipeline is running on. */ let asrDevice: "webgpu" | "wasm" = "wasm"; @@ -78,12 +76,11 @@ const asrModels = new ModelManager({ const { id, dtype } = MODELS[choice]; return [ id, - defineTransformersModel({ + transformersModel({ pipeline, task: "automatic-speech-recognition", modelId: id, dtype, - devicePolicy: asrDevices, cacheKey: env.cacheKey ?? "transformers-cache", onDevice: (device) => { asrDevice = device; @@ -118,7 +115,7 @@ async function getAsr(choice: WhisperModel) { * ASR cache (same as the old asrPromises.clear()) — not just `choice`. */ async function fallbackAsrToWasm(choice: WhisperModel) { - asrDevices.preferWasm(); + fallbackDevicePolicy.preferWasm(); asrDevice = "wasm"; await asrModels.unloadAll(); post({ From 2e70e344fafb34c05070983ac8659820a77a71f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 02:54:41 +0000 Subject: [PATCH 11/11] Bump to published weightlift@0.2.1 from npm Resolve the lockfile against the registry now that the registry-first API and transformersModel helpers are published. Co-authored-by: Wassim Gharbi --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12f36ae..c01fb0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "^0.2.0", + "weightlift": "^0.2.1", "zustand": "^5.0.14" }, "devDependencies": { @@ -10684,9 +10684,9 @@ } }, "node_modules/weightlift": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.0.tgz", - "integrity": "sha512-W0NCTaZW1CXL+bgkHjukHajnsQOgVvrD2sULvK65l/LljFi5rLDiFXKW51Bc5Xoqlqi5jGgE9BkC6N4PwLNrPw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/weightlift/-/weightlift-0.2.1.tgz", + "integrity": "sha512-ET5FGR0CB/OAmNxZX7u6BAGas6WFHj1415Xz/TXTN2unbWPIwG0R71Ogi0fB8kgEuT1liPpjGQWPbQ/9M+sUnQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index a4626ab..b048260 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-resizable-panels": "^4.12.2", - "weightlift": "^0.2.0", + "weightlift": "^0.2.1", "zustand": "^5.0.14" }, "devDependencies": {