jev-web 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/laya.mjs +152 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-web",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Run open-jev- and Laya-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/laya.mjs CHANGED
@@ -211,26 +211,11 @@ export function layaAnswersFromLogits({
211
211
  * Falls back to `res.json()` when the body isn't streamable (Node fakes/tests).
212
212
  */
213
213
  async function fetchJson(url, file, { fetchImpl, onProgress }) {
214
- const res = await fetchImpl(url);
215
- if (!res.ok) throw new Error(`tokenizer ${file}: HTTP ${res.status}`);
216
- const total = Number(res.headers?.get?.("content-length")) || 0;
217
- if (onProgress && res.body?.getReader) {
218
- const reader = res.body.getReader();
219
- const chunks = [];
220
- let loaded = 0;
221
- for (;;) {
222
- const { done, value } = await reader.read();
223
- if (done) break;
224
- chunks.push(value);
225
- loaded += value.length;
226
- onProgress({ phase: "tokenizer", file, loaded, total: total || null });
227
- }
228
- const buf = new Uint8Array(loaded);
229
- let off = 0;
230
- for (const c of chunks) { buf.set(c, off); off += c.length; }
231
- return JSON.parse(new TextDecoder().decode(buf));
232
- }
233
- return res.json();
214
+ const bytes = await fetchCachedBytes(url, {
215
+ fetchImpl,
216
+ onProgress: (p) => onProgress?.({ ...p, phase: "tokenizer", file }),
217
+ });
218
+ return JSON.parse(new TextDecoder().decode(bytes));
234
219
  }
235
220
 
236
221
  /**
@@ -300,6 +285,146 @@ const now = () => (typeof performance !== "undefined" ? performance.now() : Date
300
285
  const hfUrl = (model, revision, file) =>
301
286
  `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
302
287
 
288
+ const MODEL_CACHE = "kf-models-v1";
289
+
290
+ async function modelCacheStore() {
291
+ try {
292
+ return typeof caches !== "undefined" ? caches : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ /** Ask for persistent storage once so big weights survive eviction pressure. */
299
+ export async function requestPersistentStorage() {
300
+ try {
301
+ return (await globalThis.navigator?.storage?.persist?.()) ?? false;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Fetch bytes with a Cache-API layer. Hugging Face redirects to a *signed*
309
+ * CDN URL that changes per request, so the browser HTTP cache never hits for
310
+ * the 468 MB encoder weights; this caches them under the stable model URL
311
+ * instead (Cache API), so a page refresh is free.
312
+ *
313
+ * @returns {Promise<Uint8Array>}
314
+ */
315
+ export async function fetchCachedBytes(url, {
316
+ fetchImpl = fetch,
317
+ onProgress = null,
318
+ cacheName = MODEL_CACHE,
319
+ force = false,
320
+ persist = false,
321
+ } = {}) {
322
+ const store = await modelCacheStore();
323
+ if (persist) await requestPersistentStorage();
324
+ if (store && !force) {
325
+ try {
326
+ const hit = await store.match(url);
327
+ if (hit) {
328
+ const data = new Uint8Array(await hit.arrayBuffer());
329
+ onProgress?.({ phase: "cache", file: String(url).split("/").pop(), loaded: data.length, total: data.length });
330
+ return data;
331
+ }
332
+ } catch {
333
+ /* cache read failed — fall through to the network */
334
+ }
335
+ }
336
+ const res = await fetchImpl(url);
337
+ if (!res?.ok) throw new Error(`fetch ${url} failed (HTTP ${res?.status ?? "no response"})`);
338
+ const data = await readBytesWithProgress(res, url, onProgress);
339
+ if (store) {
340
+ try {
341
+ await store.put(url, new Response(data, {
342
+ headers: { "content-type": "application/octet-stream", "content-length": String(data.length) },
343
+ }));
344
+ } catch {
345
+ /* quota exceeded / private mode — caching is best-effort */
346
+ }
347
+ }
348
+ return data;
349
+ }
350
+
351
+ async function readBytesWithProgress(res, url, onProgress) {
352
+ const total = Number(res.headers?.get?.("content-length")) || 0;
353
+ const file = String(url).split("/").pop();
354
+ if (res.body?.getReader) {
355
+ const reader = res.body.getReader();
356
+ let received = 0;
357
+ let data = total ? new Uint8Array(total) : null;
358
+ const chunks = total ? null : [];
359
+ for (;;) {
360
+ const { done, value } = await reader.read();
361
+ if (done) break;
362
+ if (data) {
363
+ if (received + value.length > data.length) {
364
+ // content-length can describe the *encoded* size (gzip/br), so the
365
+ // decoded stream may be larger — grow instead of overflowing.
366
+ const grown = new Uint8Array(Math.max(data.length * 2, received + value.length));
367
+ grown.set(data.subarray(0, received));
368
+ data = grown;
369
+ }
370
+ data.set(value, received);
371
+ } else {
372
+ chunks.push(value);
373
+ }
374
+ received += value.length;
375
+ onProgress?.({ phase: "download", file, loaded: received, total: total || null });
376
+ }
377
+ if (!data) {
378
+ data = new Uint8Array(received);
379
+ let off = 0;
380
+ for (const c of chunks) { data.set(c, off); off += c.length; }
381
+ } else {
382
+ // the buffer may have grown past the decoded length (content-length is
383
+ // the *encoded* size) — always trim to what was actually received
384
+ data = data.subarray(0, received);
385
+ }
386
+ return data;
387
+ }
388
+ let data;
389
+ if (typeof res.arrayBuffer === "function") {
390
+ data = new Uint8Array(await res.arrayBuffer());
391
+ } else if (typeof res.text === "function") {
392
+ data = new TextEncoder().encode(await res.text());
393
+ } else if (typeof res.json === "function") {
394
+ data = new TextEncoder().encode(JSON.stringify(await res.json()));
395
+ } else {
396
+ throw new Error(`fetch ${url}: response has no readable body`);
397
+ }
398
+ onProgress?.({ phase: "download", file, loaded: data.length, total: data.length });
399
+ return data;
400
+ }
401
+
402
+ /**
403
+ * ONNX external-data loader.
404
+ *
405
+ * The pinned Laya export stores weights in `<model>.onnx.data` siblings
406
+ * (encoder ~468 MB, head ~53 MB). onnxruntime-web cannot resolve those from a
407
+ * URL by itself (`Module.MountedFiles is not available`); it needs the bytes
408
+ * passed via `session_options.externalData`. Fetch the sibling (streaming,
409
+ * with progress) and hand it over.
410
+ *
411
+ * @returns {Promise<{path:string, data:Uint8Array}|null>} null when the model
412
+ * has no external data (404) or the fetch fails.
413
+ */
414
+ export async function fetchOnnxExternalData(url, { fetchImpl = fetch, onProgress = null, force = false } = {}) {
415
+ const dataUrl = `${url}.data`;
416
+ const path = `${String(url).split("/").pop()}.data`;
417
+ try {
418
+ // Probe cheaply first: a 404 means the model has no external data.
419
+ const head = await fetchImpl(dataUrl, { method: "HEAD" }).catch(() => null);
420
+ if (head && !head.ok) return null;
421
+ const data = await fetchCachedBytes(dataUrl, { fetchImpl, onProgress, force, persist: true });
422
+ return { path, data };
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
303
428
  /**
304
429
  * Create a Laya decider.
305
430
  *
@@ -376,7 +501,13 @@ export async function createLayaDecider({
376
501
  const encUrl = encoderUrl ?? hfUrl(model, revision, `${subfolder}/encoder_q8.onnx`);
377
502
  const headUrlResolved = headUrl ?? hfUrl(model, revision, `${subfolder}/head_q8.onnx`);
378
503
  const createSession = sessionFactory
379
- ?? ((url, opts) => ortMod.InferenceSession.create(url, opts));
504
+ ?? (async (url, opts) => {
505
+ const report2 = (p) => onProgress?.(p);
506
+ const modelBytes = await fetchCachedBytes(url, { fetchImpl, onProgress: report2, persist: true });
507
+ const external = await fetchOnnxExternalData(url, { fetchImpl, onProgress: report2 });
508
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
509
+ return ortMod.InferenceSession.create(modelBytes, sessionOpts);
510
+ });
380
511
  const loadPair = async (providers) => {
381
512
  const opts = { executionProviders: providers, graphOptimizationLevel: "all" };
382
513
  const enc = await createSession(encUrl, { ...opts, progress_callback: report("encoder") });