Show "Loading model from cache" instead of "Downloading" when weights are already cached
Transformers.js's progress_callback always fires status:"download" regardless of whether the file is actually served from the Cache API — there's no cacheHit signal for the worker to forward. Since App.tsx already tracks which models are downloaded (for ModelPicker's badges), it now checks that before starting a load and threads a loadedFromCache flag into ModelLoadProgress, so the heading/hint reflect reality for the common already-cached case. Adds Decisions.md D-033.
This commit is contained in:
@@ -905,3 +905,56 @@ through a real spawned sidecar (matches `createSidecar`'s own integration
|
||||
tests, but that suite already covers the sidecar process itself — the gap
|
||||
was specifically the WebSocket/`LiveTranscriber` glue, which needs no Python
|
||||
process to exercise).
|
||||
|
||||
## D-033 — "Downloading model…" said that even when the weights came straight from cache
|
||||
**Status:** Accepted (2026-07-30)
|
||||
Reported symptom: with a model's weights already fully cached in the browser
|
||||
(Cache API, D-005), starting a load still showed `ModelLoadProgress`'s
|
||||
heading, "Downloading model `<id>`", and its "this happens once" hint — as
|
||||
if bytes were coming over the network, when nothing was.
|
||||
**Cause.** `node_modules/@huggingface/transformers/src/utils/hub.js`'s
|
||||
`loadResourceFile` computes a local `cacheHit` boolean but its
|
||||
`progress_callback` dispatch always fires `{ status: 'download', ... }`
|
||||
unconditionally — `cacheHit` only reaches the callback on a Firefox-specific
|
||||
branch that skips progress entirely. So `inference.worker.ts`'s
|
||||
`forwardProgress()` has no reliable "this came from cache" signal to forward;
|
||||
it just relays `initiate`/`download`/`progress`/`done` verbatim, and
|
||||
`ModelLoadProgress`'s heading was hardcoded to the download copy regardless.
|
||||
**Fix.** Rather than patch the vendored library for a cosmetic flag (the
|
||||
existing `bun patch` at `patches/@huggingface%2Ftransformers@4.2.0.patch` is
|
||||
reserved for the two structural D-027/D-028 session-loading changes — adding
|
||||
an unrelated third patch just for this is disproportionate and fragile
|
||||
against future upstream bumps), the app answers the question itself, before
|
||||
the load ever starts, from information it already tracks: `App.tsx` already
|
||||
queries `lib/modelCache.ts`'s `getDownloadedModels` into a `downloaded`
|
||||
`Set<string>` for `ModelPicker`'s "available offline" badges. A new
|
||||
`loadedFromCache` state is set from `downloaded.has(nextModelId)` at the top
|
||||
of `startLoad()` — the single function every load path (file-drop lazy load,
|
||||
live-start lazy load, the explicit Download button, and the error box's
|
||||
Retry) already funnels through — and reset to `false` everywhere `loadStarted`
|
||||
/`loadProgress` are reset (model switch, device-preference switch, cancel,
|
||||
the server-engine forced model switch), so a stale flag never survives into
|
||||
an unrelated load. It is threaded as a new required prop into
|
||||
`ModelLoadProgress`, whose heading/hint now read "Loading model from cache…"
|
||||
/ "This model is already downloaded — just loading it into memory." when
|
||||
true, unchanged otherwise. No worker protocol change: the decision is made
|
||||
entirely from state the UI has before it ever posts the `load` message.
|
||||
**Partial-cache handling.** `matchCachedModels` (the pure matcher backing
|
||||
`getDownloadedModels`) flags a model "downloaded" on any single cached
|
||||
`.onnx` file, not all of them — a genuinely partial download (e.g. encoder
|
||||
cached, decoder not) would misreport as fully cached here. This is the same
|
||||
imprecision `ModelPicker`'s existing "available offline" badge already
|
||||
lives with; per this fix's scope, no new finer-grained per-file cache
|
||||
probing was added — `modelCache.ts` stays the single source of truth for
|
||||
"is this downloaded," reused as-is rather than duplicated at a different
|
||||
granularity.
|
||||
**Consequence:** the loading UI now matches reality for the common
|
||||
already-cached case (reopening the app, switching back to a previously used
|
||||
model); first-time downloads are unaffected. `ModelPicker`'s own "Download"
|
||||
button never needed a matching change — it is only rendered for
|
||||
`!isDownloaded` models, so it can never show "Downloading…" for an
|
||||
already-cached one.
|
||||
**Revisit:** if `modelCache.ts` ever gains a per-file/fully-cached check
|
||||
(e.g. for the storage-management panel's exact byte accounting), reuse it
|
||||
here too so a genuinely partial cache falls back to the "Downloading" copy
|
||||
instead of "Loading from cache."
|
||||
|
||||
+19
-1
@@ -135,6 +135,10 @@ export function App() {
|
||||
const [decoding, setDecoding] = useState(false);
|
||||
const [loadStarted, setLoadStarted] = useState(false);
|
||||
const [loadProgress, setLoadProgress] = useState(initialLoadProgress);
|
||||
// Was the model already fully cached (D-005/`modelCache.ts`) when this load
|
||||
// started? Threaded into ModelLoadProgress so its heading/hint don't say
|
||||
// "Downloading" for a load that never touches the network (D-033).
|
||||
const [loadedFromCache, setLoadedFromCache] = useState(false);
|
||||
const [partialText, setPartialText] = useState("");
|
||||
const [transcribeProgress, setTranscribeProgress] = useState<{
|
||||
processedSeconds: number;
|
||||
@@ -470,6 +474,12 @@ export function App() {
|
||||
const worker = ensureWorker();
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadStarted(true);
|
||||
// D-033: `downloaded` (refreshed via getDownloadedModels, D-005) already
|
||||
// tells us whether this model's weights are cached before we ask the
|
||||
// worker to load it — capture that now, since the worker/Transformers.js
|
||||
// progress callback itself cannot distinguish a cached read from a
|
||||
// network download (see the ADR for why we don't patch that upstream).
|
||||
setLoadedFromCache(downloaded.has(nextModelId));
|
||||
setError(null);
|
||||
newLoadGate();
|
||||
const device = effectiveDevice(nextModelId, preference);
|
||||
@@ -500,6 +510,7 @@ export function App() {
|
||||
// reports fresh progress for this model.
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadedFromCache(false);
|
||||
loadGate.current = null;
|
||||
}
|
||||
|
||||
@@ -511,6 +522,7 @@ export function App() {
|
||||
// No eager reload (D-020) — the next load picks up the new preference.
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadedFromCache(false);
|
||||
loadGate.current = null;
|
||||
}
|
||||
|
||||
@@ -551,6 +563,7 @@ export function App() {
|
||||
loadGate.current = null;
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadedFromCache(false);
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
}
|
||||
@@ -956,6 +969,7 @@ export function App() {
|
||||
setResult(null);
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadedFromCache(false);
|
||||
}
|
||||
}
|
||||
}, [serverSelected, modelId, busy, liveStatus]);
|
||||
@@ -1086,7 +1100,11 @@ export function App() {
|
||||
)}
|
||||
|
||||
{showModelProgress && (
|
||||
<ModelLoadProgress modelId={modelId} progress={loadProgress} />
|
||||
<ModelLoadProgress
|
||||
modelId={modelId}
|
||||
progress={loadProgress}
|
||||
loadedFromCache={loadedFromCache}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TranscriptView
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { LoadProgressState } from "../worker/protocol";
|
||||
interface ModelLoadProgressProps {
|
||||
modelId: string;
|
||||
progress: LoadProgressState;
|
||||
/**
|
||||
* Was the model already fully cached (`lib/modelCache.ts`, D-005) when this
|
||||
* load started? Transformers.js's own progress callback cannot tell a
|
||||
* cache hit from a network download (D-033) — the app decides this itself,
|
||||
* before the load even starts, from Cache Storage.
|
||||
*/
|
||||
loadedFromCache: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -12,15 +19,18 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
/** Per-file model download progress (bytes and %), driven by load-progress messages. */
|
||||
export function ModelLoadProgress({ modelId, progress }: ModelLoadProgressProps) {
|
||||
export function ModelLoadProgress({ modelId, progress, loadedFromCache }: ModelLoadProgressProps) {
|
||||
const files = Object.values(progress.files);
|
||||
return (
|
||||
<section className="model-progress" aria-label="Model download progress">
|
||||
<h2>
|
||||
Downloading model <code>{modelId}</code>
|
||||
{loadedFromCache ? "Loading model from cache " : "Downloading model "}
|
||||
<code>{modelId}</code>
|
||||
</h2>
|
||||
<p className="model-progress-hint">
|
||||
This happens once — the weights are cached in your browser afterwards.
|
||||
{loadedFromCache
|
||||
? "This model is already downloaded — just loading it into memory."
|
||||
: "This happens once — the weights are cached in your browser afterwards."}
|
||||
</p>
|
||||
{files.length === 0 && <p className="model-progress-hint">Preparing…</p>}
|
||||
<ul>
|
||||
|
||||
Reference in New Issue
Block a user