eddie-jekyll 0.4.1 → 0.4.2

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.
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Generated by widget/build.sh from widget/src/worker.js and widget/src/lib/*.js; edit those instead.
3
3
  "use strict";
4
+ const EDDIE_ASSET_VERSION = "c0fdc503e01a";
4
5
  const EddieLib = {};
5
6
  // SPDX-License-Identifier: GPL-3.0-only
6
7
 
@@ -20,6 +21,17 @@ const EddieLib = {};
20
21
 
21
22
  const HF_HOST = "https://huggingface.co";
22
23
 
24
+ /**
25
+ * `?v=` of the runtime assets (widget, workers, wasm, glue, service
26
+ * workers): a hash of the files that produced them, stamped into every
27
+ * bundle by widget/build.sh. Deliberately not the index's version, which
28
+ * changes on every content rebuild and so re-downloaded the whole engine
29
+ * on every deploy; see "Script tag" in widget/README.md. null outside a
30
+ * bundle (the Node tests), which just means no `?v=`.
31
+ */
32
+ const ASSET_VERSION =
33
+ typeof EDDIE_ASSET_VERSION === "string" && EDDIE_ASSET_VERSION ? EDDIE_ASSET_VERSION : null;
34
+
23
35
  /** Directory of an absolute URL, without query or hash, with a trailing slash. */
24
36
  function baseUrlOf(href) {
25
37
  const u = new URL(href);
@@ -87,6 +99,7 @@ const EddieLib = {};
87
99
 
88
100
  return {
89
101
  HF_HOST,
102
+ ASSET_VERSION,
90
103
  baseUrlOf,
91
104
  versionOf,
92
105
  withVersion,
@@ -101,8 +114,9 @@ const EddieLib = {};
101
114
 
102
115
  // SPDX-License-Identifier: GPL-3.0-only
103
116
 
104
- // Dense-lane selection, download sizes, consent copy and degraded-arm notices.
105
- // Pure functions over the index manifest (see src/manifest.rs).
117
+ // Dense-lane selection and download sizes. Pure functions over the index
118
+ // manifest (see src/manifest.rs). The consent and notice copy lives in
119
+ // copy.js so the widget bundle does not carry the engine-side lane logic.
106
120
 
107
121
  (function (factory) {
108
122
  const api = factory();
@@ -170,6 +184,45 @@ const EddieLib = {};
170
184
  return /\.(safetensors|bin|pt|gguf|onnx)$/i.test(String(file));
171
185
  }
172
186
 
187
+ /** Site-relative directory the lane's model files live in (`eddie index --bundle-model`), or null for HuggingFace. */
188
+ function laneBaseUrl(lane) {
189
+ const b = lane && lane.runtime && lane.runtime.base_url;
190
+ return typeof b === "string" && b.trim() ? b.trim() : null;
191
+ }
192
+
193
+ /** Where the lane's files are downloaded from: "site" (bundled next to the index) or "huggingface". */
194
+ function laneOrigin(lane) {
195
+ return laneBaseUrl(lane) ? "site" : "huggingface";
196
+ }
197
+
198
+ /** Absolute URL of a site-bundled model file, resolved against the index URL (no version parameter yet). */
199
+ function siteModelUrl(lane, file, indexUrl) {
200
+ const base = laneBaseUrl(lane);
201
+ if (!base) return null;
202
+ return new URL(base.replace(/\/?$/, "/") + String(file).replace(/^\//, ""), indexUrl).href;
203
+ }
204
+
205
+ /**
206
+ * Name a lane's file is cached under (see urls.js cacheKey): a bundled f16
207
+ * copy and the HuggingFace original differ, so site files get their own
208
+ * prefix and never collide with a repo download of the same name.
209
+ */
210
+ function laneFileName(lane, file) {
211
+ return laneBaseUrl(lane) ? "@site/" + file : String(file);
212
+ }
213
+
214
+ /** The sidecar entry that holds `scope`'s section of `laneId`, or null when the section is in the core file. */
215
+ function sidecarFor(manifest, scope, laneId) {
216
+ const list = manifest && Array.isArray(manifest.sidecars) ? manifest.sidecars : [];
217
+ return list.find((s) => s.scope === scope && s.lane === laneId) || null;
218
+ }
219
+
220
+ /** Bytes of the sidecar file(s) a lane's chunk vectors need, counted once per file. */
221
+ function laneSidecarBytes(manifest, laneId) {
222
+ const side = sidecarFor(manifest, "chunks", laneId);
223
+ return side && Number.isFinite(Number(side.bytes)) ? Number(side.bytes) : 0;
224
+ }
225
+
173
226
  /**
174
227
  * Why the WASM loader (init_dense_wasm) cannot run a wasm-candle lane, or
175
228
  * null when it can: it accepts BERT-family lanes with config.json,
@@ -197,6 +250,11 @@ const EddieLib = {};
197
250
  const lanes = (manifest && Array.isArray(manifest.dense)) ? manifest.dense : [];
198
251
  const runtime = (opts && opts.denseRuntime) || "auto";
199
252
  const hasWebGpu = !!(opts && opts.hasWebGpu);
253
+ // A pinned lane id (the visitor picked one in the settings panel, or the
254
+ // site set data-dense-lane): the choice narrows to that lane, and if it
255
+ // is not runnable here the result is no dense arm rather than a silently
256
+ // different model.
257
+ const pinned = (opts && opts.laneId) ? String(opts.laneId) : null;
200
258
  const webgpu = lanes.filter(isWebGpuLane);
201
259
  const skipped = [];
202
260
  const wasm = lanes.filter(isWasmLane).filter((lane) => {
@@ -207,6 +265,9 @@ const EddieLib = {};
207
265
  if (lanes.length === 0) {
208
266
  return { candidates: [], skipped, reason: "index has no dense lane" };
209
267
  }
268
+ if (runtime === "off") {
269
+ return { candidates: [], skipped, reason: "dense search is turned off" };
270
+ }
210
271
  let candidates;
211
272
  if (runtime === "wasm") {
212
273
  candidates = wasm;
@@ -215,9 +276,11 @@ const EddieLib = {};
215
276
  } else {
216
277
  candidates = hasWebGpu ? webgpu.concat(wasm) : wasm;
217
278
  }
279
+ if (pinned) candidates = candidates.filter((lane) => lane.id === pinned);
218
280
  let reason = null;
219
281
  if (candidates.length === 0) {
220
- if (runtime === "wasm") reason = skipped.length ? "no wasm-candle lane the WASM loader can run" : "index has no wasm-candle lane";
282
+ if (pinned) reason = `lane ${pinned} cannot run here`;
283
+ else if (runtime === "wasm") reason = skipped.length ? "no wasm-candle lane the WASM loader can run" : "index has no wasm-candle lane";
221
284
  else if (!hasWebGpu && webgpu.length && !wasm.length) reason = "no WebGPU adapter";
222
285
  else if (runtime === "webgpu" && !hasWebGpu) reason = "no WebGPU adapter";
223
286
  else if (runtime === "webgpu") reason = "index has no webgpu-onnx lane";
@@ -232,12 +295,70 @@ const EddieLib = {};
232
295
  return runtime.dtype || "fp32";
233
296
  }
234
297
 
235
- /** Approximate bytes a lane downloads on first use, or null when unknown. */
298
+ /** The exact byte count the manifest declares for a bundled model (`runtime.bytes`), or null. */
299
+ function laneDeclaredBytes(lane) {
300
+ const declared = lane && lane.runtime ? Number(lane.runtime.bytes) : NaN;
301
+ return Number.isFinite(declared) && declared > 0 ? declared : null;
302
+ }
303
+
304
+ /**
305
+ * Bytes a lane downloads on first use, or null when unknown: the exact
306
+ * count the manifest carries for a bundled model (`runtime.bytes`, written
307
+ * by `eddie index --bundle-model`), else the table estimate for known
308
+ * HuggingFace repos. The table describes the f32 originals; a bundled f16
309
+ * copy without `runtime.bytes` should be measured instead (the engine
310
+ * HEADs the files), never estimated from the table.
311
+ */
236
312
  function laneDownloadBytes(lane) {
313
+ const declared = laneDeclaredBytes(lane);
314
+ if (declared != null) return declared;
237
315
  const repo = (laneRepo(lane) || "").toLowerCase();
238
316
  return Object.prototype.hasOwnProperty.call(DOWNLOAD_SIZES, repo) ? DOWNLOAD_SIZES[repo] : null;
239
317
  }
240
318
 
319
+ return {
320
+ DEFAULT_WASM_FILES,
321
+ DOWNLOAD_SIZES,
322
+ laneKind,
323
+ isWasmLane,
324
+ isWebGpuLane,
325
+ laneRepo,
326
+ laneRevision,
327
+ laneFiles,
328
+ isWeightsFile,
329
+ laneBaseUrl,
330
+ laneOrigin,
331
+ siteModelUrl,
332
+ laneFileName,
333
+ sidecarFor,
334
+ laneSidecarBytes,
335
+ wasmLaneProblem,
336
+ chooseDenseLanes,
337
+ pickDtype,
338
+ laneDeclaredBytes,
339
+ laneDownloadBytes,
340
+ };
341
+ });
342
+
343
+ // SPDX-License-Identifier: GPL-3.0-only
344
+
345
+ // User-facing copy shared by the widget and the search engine: byte
346
+ // formatting, the download consent card and the degraded-arm notices.
347
+ // Split out of lanes.js so the widget bundle carries only this, not the
348
+ // engine-side lane selection.
349
+
350
+ (function (factory) {
351
+ const api = factory();
352
+ if (typeof module === "object" && module && module.exports) {
353
+ module.exports = api;
354
+ } else if (typeof EddieLib === "object" && EddieLib) {
355
+ Object.assign(EddieLib, api);
356
+ } else {
357
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
358
+ }
359
+ })(function () {
360
+ "use strict";
361
+
241
362
  function formatBytes(n) {
242
363
  if (n == null || !Number.isFinite(n)) return "unknown size";
243
364
  if (n >= 1e9) {
@@ -251,16 +372,21 @@ const EddieLib = {};
251
372
 
252
373
  /**
253
374
  * Consent card copy for a model download. `consentText` (site override) may
254
- * contain `{size}` and `{model}` placeholders.
375
+ * contain `{size}`, `{model}` and `{origin}` placeholders.
376
+ * sizeBytes model download (null when unknown)
377
+ * origin "site" | "huggingface" (where the model is fetched from)
378
+ * sidecarBytes index vectors fetched from the site for this lane (0 when in the core index)
255
379
  */
256
380
  function consentCopy(opts) {
257
381
  const size = formatBytes(opts.sizeBytes);
258
382
  const model = opts.model || "the search model";
383
+ const origin = opts.origin === "site" ? "this site" : "huggingface.co";
259
384
  if (opts.consentText) {
260
- return opts.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, model);
385
+ return opts.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, model).replace(/\{origin\}/g, origin);
261
386
  }
262
- const sized = opts.sizeBytes == null ? "a one-time download (size unknown)" : `a one-time ${size} download`;
263
- let text = `Semantic search runs a language model in your browser. That needs ${sized} of ${model}, kept in your browser's cache for next time.`;
387
+ const sized = opts.sizeBytes == null ? `a one-time download (size unknown) from ${origin}` : `a one-time ${size} download from ${origin}`;
388
+ const side = opts.sidecarBytes > 0 ? `, plus ${formatBytes(opts.sidecarBytes)} of index vectors from this site` : "";
389
+ let text = `Semantic search runs ${model} in your browser. That needs ${sized}${side}, kept in your browser's cache for next time.`;
264
390
  if (opts.saveData) {
265
391
  text = "Data saver is on. " + text;
266
392
  }
@@ -271,8 +397,15 @@ const EddieLib = {};
271
397
  * Drop degraded notes that describe the index design rather than a failure
272
398
  * (an index without a dense lane or sparse arm is not "degraded").
273
399
  */
400
+ /**
401
+ * Drop the "degraded" entries that describe how the index or the visitor
402
+ * chose to run, not something going wrong: an index built without a lane,
403
+ * and a visitor who picked keyword-only search in the settings panel.
404
+ */
274
405
  function filterDesignDegraded(degraded) {
275
- return (degraded || []).filter((d) => !/index has no (dense lane|sparse arm)/.test(d));
406
+ return (degraded || []).filter(
407
+ (d) => !/index has no (dense lane|sparse arm)|dense search is turned off/.test(d)
408
+ );
276
409
  }
277
410
 
278
411
  /** Human notice when a semantic arm is missing, or null when nothing to say. */
@@ -290,19 +423,6 @@ const EddieLib = {};
290
423
  }
291
424
 
292
425
  return {
293
- DEFAULT_WASM_FILES,
294
- DOWNLOAD_SIZES,
295
- laneKind,
296
- isWasmLane,
297
- isWebGpuLane,
298
- laneRepo,
299
- laneRevision,
300
- laneFiles,
301
- isWeightsFile,
302
- wasmLaneProblem,
303
- chooseDenseLanes,
304
- pickDtype,
305
- laneDownloadBytes,
306
426
  formatBytes,
307
427
  consentCopy,
308
428
  filterDesignDegraded,
@@ -349,8 +469,14 @@ const EddieLib = {};
349
469
  }
350
470
 
351
471
  async function readBody(response, onProgress) {
352
- const lenHeader = response.headers && response.headers.get ? response.headers.get("Content-Length") : null;
353
- const total = lenHeader && /^\d+$/.test(lenHeader) && Number(lenHeader) > 0 ? Number(lenHeader) : null;
472
+ const headers = response.headers && response.headers.get ? response.headers : null;
473
+ const lenHeader = headers ? headers.get("Content-Length") : null;
474
+ // A compressed response (Content-Encoding: br/gzip) announces the wire
475
+ // size while the stream yields decoded bytes: the announced total is
476
+ // neither a buffer size nor a progress denominator then.
477
+ const encoding = headers ? String(headers.get("Content-Encoding") || "").trim().toLowerCase() : "";
478
+ const encoded = encoding !== "" && encoding !== "identity";
479
+ const total = !encoded && lenHeader && /^\d+$/.test(lenHeader) && Number(lenHeader) > 0 ? Number(lenHeader) : null;
354
480
  if (onProgress) onProgress(0, total);
355
481
  if (!response.body || typeof response.body.getReader !== "function") {
356
482
  const buf = new Uint8Array(await response.arrayBuffer());
@@ -466,702 +592,1129 @@ const EddieLib = {};
466
592
 
467
593
  // SPDX-License-Identifier: GPL-3.0-only
468
594
 
469
- // Eddie search worker (classic worker).
595
+ // Eddie search engine, host-independent.
596
+ //
597
+ // Everything the search worker does (manifest, lane choice, downloads with
598
+ // timeouts and retry, IndexedDB cache, sidecars, sparse tokenizer, the
599
+ // transformers.js WebGPU lane, the WASM calls and the consent handshake)
600
+ // lives here, behind an `env` the host supplies:
601
+ //
602
+ // createSearchEngine({
603
+ // post(message) broadcast sink: status and ready events
604
+ // loadWasm(baseUrl, variant)
605
+ // -> Promise of the wasm-bindgen API object;
606
+ // variant is "lite" (always first) or
607
+ // "dense" (only when a wasm-candle lane is
608
+ // about to run); a host without the dense
609
+ // module rejects and the lane degrades
610
+ // loadTransformers() -> Promise of the transformers.js module
611
+ // canRunWebGpuLane false when this host cannot run webgpu-onnx lanes
612
+ // navigator, indexedDB optional overrides (tests)
613
+ // })
614
+ //
615
+ // Lite first: the lite module (no model code, a quarter of the dense one
616
+ // after brotli) answers keyword and sparse queries at once. When a visitor
617
+ // accepts a CPU dense lane the dense module is loaded and the index, its
618
+ // attached sidecars and the sparse tokenizer are handed over to it; the
619
+ // bytes are kept only while that hand-over may still happen.
470
620
  //
471
- // widget/build.sh concatenates widget/src/lib/*.js ahead of this file, so the
472
- // pure helpers are available as `EddieLib`. The WASM glue is loaded with
473
- // importScripts; transformers.js (WebGPU dense lane) with a dynamic import().
621
+ // `engine.handle(msg, reply)` dispatches one protocol message; `reply` is
622
+ // the sink for that message's own answers (search_result, cache_result,
623
+ // error with requestId ...). In a dedicated worker `post` and `reply` are
624
+ // both `self.postMessage`; in the service worker `reply` is the requesting
625
+ // page's port and `post` fans out to every connected page.
474
626
  //
475
- // Protocol (main thread -> worker):
476
- // init {indexUrl, baseUrl, version?, denseRuntime?, consent?}
477
- // cache_check {requestId}
478
- // search {requestId, query, topK?, mode?, evidence?, qa?}
479
- // page {requestId, url}
480
- // chunk {requestId, id}
481
- // qa {requestId, query, k?}
482
- // (worker -> main thread):
483
- // status {state, ...} loading_wasm | loading_index | index_ready |
484
- // consent_required | downloading_model |
485
- // loading_model | error
486
- // ready {lanes, lane, arms, degraded, manifest}
487
- // cache_result {requestId, cached, lane, sizeBytes}
488
- // search_result {requestId, results, arms, degraded, mode, qa?}
489
- // page_result {requestId, page}
490
- // chunk_result {requestId, chunk}
491
- // qa_result {requestId, hits}
492
- // error {requestId?, message, fatal?, unsupported?}
627
+ // The message shapes are documented in widget/README.md ("Worker protocol").
493
628
 
494
- "use strict";
629
+ (function (factory) {
630
+ const api = factory();
631
+ if (typeof module === "object" && module && module.exports) {
632
+ module.exports = api;
633
+ } else if (typeof EddieLib === "object" && EddieLib) {
634
+ Object.assign(EddieLib, api);
635
+ } else {
636
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
637
+ }
638
+ })(function () {
639
+ "use strict";
495
640
 
496
- const IDB_NAME = "eddie-models";
497
- const IDB_VERSION = 2;
498
- const IDB_FILES = "files";
499
- const IDB_META = "meta";
500
- const TRANSFORMERS_URL = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0";
501
- const PROGRESS_INTERVAL_MS = 80;
641
+ const IDB_NAME = "eddie-models";
642
+ const IDB_VERSION = 2;
643
+ const IDB_FILES = "files";
644
+ const IDB_META = "meta";
645
+ const PROGRESS_INTERVAL_MS = 80;
646
+ const NOT_LOADED = "index not loaded yet";
502
647
 
503
- const lib = EddieLib;
648
+ /** What a wasm-bindgen API object can do; older glue without capabilities() is the full build. */
649
+ function wasmCapabilities(api) {
650
+ if (api && typeof api.capabilities === "function") {
651
+ try {
652
+ const c = JSON.parse(api.capabilities());
653
+ return { dense_wasm: !!c.dense_wasm, sparse: c.sparse !== false, version: c.version || null };
654
+ } catch (_) {
655
+ // fall through to feature detection
656
+ }
657
+ }
658
+ return { dense_wasm: !!(api && typeof api.init_dense_wasm === "function"), sparse: true, version: null };
659
+ }
504
660
 
505
- const state = {
506
- phase: "idle", // idle | loading | awaiting_consent | ready | error | dead
507
- initRunning: false,
508
- rerun: false,
509
- baseUrl: "",
510
- version: null,
511
- indexUrl: null,
512
- denseRuntime: "auto",
513
- consent: Object.create(null), // lane id -> true
514
- wasmReady: false,
515
- indexLoaded: false,
516
- manifest: null,
517
- candidates: [],
518
- candidateReason: null,
519
- laneIndex: 0,
520
- dense: null, // { lane, kind: "wasm" | "webgpu", embed? }
521
- sparse: false,
522
- degraded: [],
523
- gpu: null, // { adapter, hasF16, maxBufferSize } | false
524
- db: undefined, // IDBDatabase | null (unavailable)
525
- };
661
+ function createSearchEngine(env) {
662
+ const lib = typeof EddieLib === "object" && EddieLib ? EddieLib : env.lib;
663
+ const post = env.post;
664
+ const nav = env.navigator !== undefined ? env.navigator : globalThis.navigator;
665
+ const idb = env.indexedDB !== undefined ? env.indexedDB : globalThis.indexedDB;
666
+ const canRunWebGpuLane = env.canRunWebGpuLane !== false;
667
+
668
+ let wasm = null; // wasm-bindgen API object once loaded (lite, then dense after a hand-over)
669
+ let caps = null; // wasmCapabilities(wasm)
670
+
671
+ const state = {
672
+ phase: "idle", // idle | loading | awaiting_consent | awaiting_tier | ready | error | dead
673
+ initRunning: false,
674
+ rerun: false,
675
+ baseUrl: "",
676
+ version: null,
677
+ indexUrl: null,
678
+ loadedIndexUrl: null,
679
+ denseRuntime: "auto",
680
+ laneId: null, // visitor-pinned dense lane (settings panel), or null
681
+ consent: Object.create(null), // lane id -> true
682
+ wasmReady: false,
683
+ indexLoaded: false,
684
+ manifest: null,
685
+ // Bytes kept for the lite -> dense hand-over (dropped once it is done or impossible).
686
+ indexBytes: null,
687
+ sparseBytes: null,
688
+ sidecarBytes: Object.create(null), // file -> Uint8Array
689
+ attachedSidecars: [], // file names attached to the current module
690
+ candidates: [],
691
+ candidateReason: null,
692
+ hostSkipped: [], // webgpu-onnx lane ids this host cannot run
693
+ laneIndex: 0,
694
+ dense: null, // { lane, kind: "wasm" | "webgpu", embed? }
695
+ sparse: false,
696
+ degraded: [],
697
+ gpu: null, // { adapter, hasF16, maxBufferSize } | false
698
+ db: undefined, // IDBDatabase | null (unavailable)
699
+ siteSizes: Object.create(null), // lane id -> measured bundled download bytes
700
+ };
701
+
702
+ // -- dispatch ---------------------------------------------------------
703
+
704
+ function handle(msg, reply) {
705
+ const m = msg || {};
706
+ const out = reply || post;
707
+ switch (m.type) {
708
+ case "init":
709
+ return handleInit(m, out);
710
+ case "cache_check":
711
+ return handleCacheCheck(m, out);
712
+ case "cache_clear":
713
+ return handleCacheClear(m, out);
714
+ case "search":
715
+ return handleSearch(m, out);
716
+ case "page":
717
+ return handleLookup(m, out, "page_result", "page", () => wasm.page(String(m.url)));
718
+ case "chunk":
719
+ return handleLookup(m, out, "chunk_result", "chunk", () => wasm.chunk(Number(m.id)));
720
+ case "qa":
721
+ return handleQa(m, out);
722
+ default:
723
+ postError(out, m.requestId, `unknown message type ${String(m.type)}`);
724
+ return Promise.resolve();
725
+ }
726
+ }
526
727
 
527
- self.onmessage = function (e) {
528
- const msg = e.data || {};
529
- switch (msg.type) {
530
- case "init":
531
- handleInit(msg);
532
- break;
533
- case "cache_check":
534
- handleCacheCheck(msg);
535
- break;
536
- case "search":
537
- handleSearch(msg);
538
- break;
539
- case "page":
540
- handleLookup(msg, "page_result", "page", () => wasm_bindgen.page(String(msg.url)));
541
- break;
542
- case "chunk":
543
- handleLookup(msg, "chunk_result", "chunk", () => wasm_bindgen.chunk(Number(msg.id)));
544
- break;
545
- case "qa":
546
- handleQa(msg);
547
- break;
548
- default:
549
- postError(msg.requestId, `unknown message type ${String(msg.type)}`);
550
- }
551
- };
728
+ /**
729
+ * The degraded list as reported to the host: design choices dropped
730
+ * (copy.js), and the dense arm's entries dropped entirely when the
731
+ * visitor turned the dense arm off -- they asked for keyword and sparse,
732
+ * so nothing is missing.
733
+ */
734
+ function reportedDegraded(list) {
735
+ const filtered = lib.filterDesignDegraded(unique(list));
736
+ if (state.denseRuntime !== "off") return filtered;
737
+ return filtered.filter((d) => !String(d).startsWith("dense:"));
738
+ }
552
739
 
553
- // -- init ---------------------------------------------------------------
740
+ /** Snapshot for the service worker's `state` reply. */
741
+ function snapshot() {
742
+ return {
743
+ phase: state.phase,
744
+ indexUrl: state.loadedIndexUrl,
745
+ version: state.version,
746
+ indexLoaded: state.indexLoaded,
747
+ lane: state.dense ? state.dense.lane.id : null,
748
+ runtime: state.dense ? state.dense.kind : null,
749
+ arms: state.indexLoaded ? { dense: !!state.dense, sparse: state.sparse, bm25: true } : null,
750
+ degraded: reportedDegraded(state.degraded),
751
+ manifest: state.manifest ? manifestSummary() : null,
752
+ lanes: laneList(),
753
+ hostSkippedLanes: state.hostSkipped.slice(),
754
+ wasm: caps ? (caps.dense_wasm ? "dense" : "lite") : null,
755
+ };
756
+ }
554
757
 
555
- function handleInit(msg) {
556
- if (state.phase === "dead") {
557
- postError(null, "search engine crashed; reload the page to retry", true);
558
- return;
559
- }
560
- if (msg.indexUrl) state.indexUrl = String(msg.indexUrl);
561
- if (msg.baseUrl != null) state.baseUrl = String(msg.baseUrl);
562
- if (msg.version != null) state.version = msg.version ? String(msg.version) : null;
563
- if (msg.denseRuntime) state.denseRuntime = String(msg.denseRuntime);
564
- if (msg.consent === true) {
565
- const lane = currentCandidate();
566
- if (lane) state.consent[lane.id] = true;
567
- if (msg.consentLane) state.consent[String(msg.consentLane)] = true;
568
- }
569
- runInit();
570
- }
758
+ // -- init ---------------------------------------------------------------
571
759
 
572
- async function runInit() {
573
- if (state.initRunning) {
574
- state.rerun = true;
575
- return;
576
- }
577
- state.initRunning = true;
578
- try {
579
- state.phase = "loading";
580
- await ensureWasm();
581
- await ensureIndex();
582
- await ensureDb();
583
- await ensureGpu();
584
- await ensureSparse();
585
- const outcome = await ensureDense();
586
- if (outcome === "consent") {
587
- state.phase = "awaiting_consent";
588
- return;
589
- }
590
- state.phase = "ready";
591
- postReady();
592
- evictStaleFiles();
593
- } catch (err) {
594
- state.phase = isFatal(err) ? "dead" : "error";
595
- postStatus("error", {
596
- message: describe(err),
597
- fatal: isFatal(err),
598
- unsupported: isUnsupported(err),
599
- });
600
- } finally {
601
- state.initRunning = false;
602
- if (state.rerun) {
603
- state.rerun = false;
604
- runInit();
760
+ function handleInit(msg, reply) {
761
+ if (state.phase === "dead") {
762
+ postError(reply, null, "search engine crashed; reload the page to retry", true);
763
+ return Promise.resolve();
764
+ }
765
+ if (msg.indexUrl) state.indexUrl = String(msg.indexUrl);
766
+ if (msg.baseUrl != null) state.baseUrl = String(msg.baseUrl);
767
+ if (msg.version != null) state.version = msg.version ? String(msg.version) : null;
768
+ const wasRuntime = state.denseRuntime;
769
+ const wasLane = state.laneId;
770
+ if (msg.denseRuntime) state.denseRuntime = String(msg.denseRuntime);
771
+ if (msg.laneId !== undefined) state.laneId = msg.laneId ? String(msg.laneId) : null;
772
+ if (state.denseRuntime !== wasRuntime || state.laneId !== wasLane) resetDenseSelection();
773
+ if (msg.consent === true) {
774
+ const lane = currentCandidate();
775
+ if (lane) state.consent[lane.id] = true;
776
+ if (msg.consentLane) state.consent[String(msg.consentLane)] = true;
777
+ }
778
+ return runInit();
605
779
  }
606
- }
607
- }
608
780
 
609
- async function ensureWasm() {
610
- if (state.wasmReady) return;
611
- postStatus("loading_wasm");
612
- if (typeof WebAssembly !== "object") {
613
- throw unsupported("This browser can't run WebAssembly.");
614
- }
615
- try {
616
- importScripts(lib.assetUrl(state.baseUrl, "eddie-wasm.js", state.version));
617
- await wasm_bindgen({ module_or_path: lib.assetUrl(state.baseUrl, "eddie.wasm", state.version) });
618
- } catch (err) {
619
- if (err instanceof WebAssembly.CompileError || err instanceof WebAssembly.LinkError) {
620
- throw unsupported("This browser can't run the search engine (WebAssembly SIMD is required).");
621
- }
622
- throw err;
623
- }
624
- state.wasmReady = true;
625
- }
781
+ async function runInit() {
782
+ if (state.initRunning) {
783
+ state.rerun = true;
784
+ return;
785
+ }
786
+ state.initRunning = true;
787
+ try {
788
+ state.phase = "loading";
789
+ await ensureWasm();
790
+ await ensureIndex();
791
+ await ensureDb();
792
+ await ensureGpu();
793
+ chooseLanes();
794
+ await ensureSparse();
795
+ const outcome = await ensureDense();
796
+ if (outcome === "consent") {
797
+ state.phase = "awaiting_consent";
798
+ return;
799
+ }
800
+ if (outcome === "tier") {
801
+ state.phase = "awaiting_tier";
802
+ return;
803
+ }
804
+ releaseHandoverBytes();
805
+ state.phase = "ready";
806
+ postReady();
807
+ evictStaleFiles();
808
+ } catch (err) {
809
+ state.phase = isFatal(err) ? "dead" : "error";
810
+ postStatus("error", {
811
+ message: describe(err),
812
+ fatal: isFatal(err),
813
+ unsupported: isUnsupported(err),
814
+ });
815
+ } finally {
816
+ state.initRunning = false;
817
+ if (state.rerun) {
818
+ state.rerun = false;
819
+ runInit();
820
+ }
821
+ }
822
+ }
626
823
 
627
- async function ensureIndex() {
628
- if (state.indexLoaded) return;
629
- if (!state.indexUrl) throw new Error("init: indexUrl is required");
630
- postStatus("loading_index", { progress: null });
631
- let last = 0;
632
- const bytes = await lib.fetchBytes(state.indexUrl, {
633
- timeoutMs: 180000,
634
- onProgress: (loaded, total) => {
635
- const now = Date.now();
636
- if (total && now - last < PROGRESS_INTERVAL_MS && loaded !== total) return;
637
- last = now;
638
- postStatus("loading_index", { progress: total ? loaded / total : null, loaded, total });
639
- },
640
- });
641
- state.manifest = JSON.parse(wasm_bindgen.manifest(bytes));
642
- wasm_bindgen.init_index(bytes);
643
- state.indexLoaded = true;
644
- state.dense = null;
645
- state.sparse = false;
646
- state.degraded = [];
647
- state.laneIndex = 0;
648
- postStatus("index_ready", { manifest: manifestSummary(), lanes: laneList() });
649
- }
824
+ async function loadWasmVariant(variant) {
825
+ if (typeof WebAssembly !== "object") {
826
+ throw unsupported("This browser can't run WebAssembly.");
827
+ }
828
+ let api;
829
+ try {
830
+ api = await env.loadWasm(state.baseUrl, variant);
831
+ } catch (err) {
832
+ if (typeof WebAssembly === "object" && (err instanceof WebAssembly.CompileError || err instanceof WebAssembly.LinkError)) {
833
+ throw unsupported("This browser can't run the search engine (WebAssembly SIMD is required).");
834
+ }
835
+ throw err;
836
+ }
837
+ if (!api || typeof api.search !== "function") {
838
+ throw new Error(`loadWasm(${variant}) did not return the Eddie WASM API`);
839
+ }
840
+ return api;
841
+ }
842
+
843
+ async function ensureWasm() {
844
+ if (state.wasmReady) return;
845
+ postStatus("loading_wasm", { variant: "lite" });
846
+ wasm = await loadWasmVariant("lite");
847
+ caps = wasmCapabilities(wasm);
848
+ state.wasmReady = true;
849
+ }
650
850
 
651
- async function ensureGpu() {
652
- if (state.gpu !== null) return;
653
- state.gpu = false;
654
- try {
655
- if (state.denseRuntime !== "wasm" && self.navigator && navigator.gpu && typeof navigator.gpu.requestAdapter === "function") {
656
- const adapter = await navigator.gpu.requestAdapter();
657
- if (adapter) {
658
- state.gpu = {
659
- adapter,
660
- hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
661
- maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
662
- };
851
+ /**
852
+ * Hand the loaded index over to the dense module (candle embedder) when
853
+ * the running one cannot embed. Everything the lite module was given is
854
+ * given again: index bytes, attached sidecars, a fetched sparse tokenizer.
855
+ */
856
+ async function ensureDenseWasm() {
857
+ if (caps && caps.dense_wasm) return;
858
+ if (!state.indexBytes) throw new Error("dense hand-over: index bytes were released");
859
+ postStatus("loading_wasm", { variant: "dense" });
860
+ const dense = await loadWasmVariant("dense");
861
+ const dcaps = wasmCapabilities(dense);
862
+ if (!dcaps.dense_wasm) throw new Error("eddie-dense.wasm has no CPU embedder (capabilities.dense_wasm is false)");
863
+ dense.init_index(state.indexBytes);
864
+ for (const file of state.attachedSidecars) {
865
+ if (state.sidecarBytes[file]) dense.attach_sidecar(state.sidecarBytes[file]);
663
866
  }
867
+ if (state.sparse && state.sparseBytes) dense.init_sparse_tokenizer(state.sparseBytes);
868
+ wasm = dense;
869
+ caps = dcaps;
664
870
  }
665
- } catch (err) {
666
- console.warn("eddie: WebGPU adapter probe failed", err);
667
- state.gpu = false;
668
- }
669
- const choice = lib.chooseDenseLanes(state.manifest, {
670
- denseRuntime: state.denseRuntime,
671
- hasWebGpu: !!state.gpu,
672
- });
673
- state.candidates = choice.candidates;
674
- state.candidateReason = choice.reason;
675
- for (const { lane, reason } of choice.skipped) {
676
- console.warn(`eddie: dense lane ${lane.id} skipped: ${reason}`);
677
- state.degraded.push(`dense: lane ${lane.id} skipped: ${reason}`);
678
- }
679
- }
680
871
 
681
- async function ensureSparse() {
682
- if (state.sparse) return;
683
- const spec = state.manifest && state.manifest.sparse;
684
- if (!spec) return;
685
- if (state.degraded.some((d) => d.startsWith("sparse:"))) return;
686
- try {
687
- const repo = spec.tokenizer || spec.model;
688
- const rev = spec.revision || "main";
689
- let bytes = await getModelFile(repo, rev, "tokenizer.json", "sparse");
690
- let ok = await lib.verifySha256(bytes, spec.vocab_hash);
691
- if (!ok && bytes.fromCache) {
692
- await idbDelete(lib.cacheKey(repo, rev, "tokenizer.json"));
693
- bytes = await getModelFile(repo, rev, "tokenizer.json", "sparse", { noCache: true });
694
- ok = await lib.verifySha256(bytes, spec.vocab_hash);
695
- }
696
- if (!ok) {
697
- throw new Error("tokenizer.json SHA-256 does not match the index's vocab_hash");
698
- }
699
- wasm_bindgen.init_sparse_tokenizer(bytes);
700
- state.sparse = true;
701
- } catch (err) {
702
- if (isFatal(err)) throw err;
703
- console.warn("eddie: sparse arm unavailable", err);
704
- state.degraded.push(`sparse: ${describe(err)}`);
705
- }
706
- }
872
+ /** A wasm-candle lane could still ask for the dense module later. */
873
+ function handoverPossible() {
874
+ if (!caps || caps.dense_wasm || state.dense) return false;
875
+ return state.candidates.slice(state.laneIndex).some((lane) => lib.isWasmLane(lane));
876
+ }
707
877
 
708
- /** Returns "ok" (a lane is loaded), "consent" (waiting for the visitor) or "none". */
709
- async function ensureDense() {
710
- if (state.dense) return "ok";
711
- for (; state.laneIndex < state.candidates.length; state.laneIndex++) {
712
- const lane = state.candidates[state.laneIndex];
713
- try {
714
- const cached = await laneCached(lane);
715
- if (!cached && !state.consent[lane.id]) {
716
- postStatus("consent_required", {
717
- lane: laneSummary(lane),
718
- sizeBytes: lib.laneDownloadBytes(lane),
719
- saveData: !!(self.navigator && navigator.connection && navigator.connection.saveData),
720
- });
721
- return "consent";
878
+ function releaseHandoverBytes() {
879
+ if (handoverPossible()) return;
880
+ state.indexBytes = null;
881
+ state.sparseBytes = null;
882
+ state.sidecarBytes = Object.create(null);
883
+ }
884
+
885
+ async function ensureIndex() {
886
+ if (!state.indexUrl) throw new Error("init: indexUrl is required");
887
+ if (state.indexLoaded && state.loadedIndexUrl === state.indexUrl) return;
888
+ postStatus("loading_index", { progress: null });
889
+ let last = 0;
890
+ const bytes = await lib.fetchBytes(state.indexUrl, {
891
+ fetch: env.fetch,
892
+ timeoutMs: 180000,
893
+ onProgress: (loaded, total) => {
894
+ const now = Date.now();
895
+ if (total && now - last < PROGRESS_INTERVAL_MS && loaded !== total) return;
896
+ last = now;
897
+ postStatus("loading_index", { progress: total ? loaded / total : null, loaded, total });
898
+ },
899
+ });
900
+ state.manifest = JSON.parse(wasm.manifest(bytes));
901
+ wasm.init_index(bytes);
902
+ state.indexBytes = bytes;
903
+ state.indexLoaded = true;
904
+ state.loadedIndexUrl = state.indexUrl;
905
+ state.dense = null;
906
+ state.sparse = false;
907
+ state.sparseBytes = null;
908
+ state.sidecarBytes = Object.create(null);
909
+ state.attachedSidecars = [];
910
+ state.degraded = [];
911
+ state.hostSkipped = [];
912
+ state.laneIndex = 0;
913
+ state.gpu = null;
914
+ state.siteSizes = Object.create(null);
915
+ postStatus("index_ready", { manifest: manifestSummary(), lanes: laneList(), hostSkippedLanes: state.hostSkipped.slice() });
916
+ }
917
+
918
+ async function ensureGpu() {
919
+ if (state.gpu !== null) return;
920
+ state.gpu = false;
921
+ try {
922
+ if (state.denseRuntime !== "wasm" && nav && nav.gpu && typeof nav.gpu.requestAdapter === "function") {
923
+ const adapter = await nav.gpu.requestAdapter();
924
+ if (adapter) {
925
+ state.gpu = {
926
+ adapter,
927
+ hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
928
+ maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
929
+ };
930
+ }
931
+ }
932
+ } catch (err) {
933
+ console.warn("eddie: WebGPU adapter probe failed", err);
934
+ state.gpu = false;
722
935
  }
723
- if (lib.isWasmLane(lane)) {
724
- await loadWasmLane(lane);
725
- } else {
726
- await loadWebGpuLane(lane);
936
+ }
937
+
938
+ /**
939
+ * Pick the dense lanes to try, in order. Runs on every init, not just the
940
+ * first: the visitor can change `denseRuntime` or pin a lane from the
941
+ * settings panel at any time, and the answer has to change with it.
942
+ */
943
+ function chooseLanes() {
944
+ const choice = lib.chooseDenseLanes(state.manifest, {
945
+ denseRuntime: state.denseRuntime,
946
+ laneId: state.laneId,
947
+ hasWebGpu: !!state.gpu && canRunWebGpuLane,
948
+ });
949
+ state.candidates = choice.candidates;
950
+ state.candidateReason = choice.reason;
951
+ if (!canRunWebGpuLane) {
952
+ state.hostSkipped = (state.manifest.dense || []).filter(lib.isWebGpuLane).map((l) => l.id);
953
+ }
954
+ for (const { lane, reason } of choice.skipped) {
955
+ console.warn(`eddie: dense lane ${lane.id} skipped: ${reason}`);
956
+ state.degraded.push(`dense: lane ${lane.id} skipped: ${reason}`);
727
957
  }
728
- return "ok";
729
- } catch (err) {
730
- if (isFatal(err)) throw err;
731
- console.warn(`eddie: dense lane ${lane.id} failed`, err);
732
- state.degraded.push(`dense: lane ${lane.id} failed: ${describe(err)}`);
733
958
  }
734
- }
735
- if (state.candidates.length === 0 && state.candidateReason && (state.manifest.dense || []).length > 0) {
736
- if (!state.degraded.some((d) => d.startsWith("dense:"))) {
737
- state.degraded.push(`dense: ${state.candidateReason}`);
959
+
960
+ /**
961
+ * Forget which dense lane is running so the next init picks again. Called
962
+ * when the visitor changes the lane or turns the dense arm off.
963
+ */
964
+ function resetDenseSelection() {
965
+ state.dense = null;
966
+ state.laneIndex = 0;
967
+ state.degraded = state.degraded.filter((d) => !String(d).startsWith("dense:"));
968
+ // The hand-over bytes are dropped once no wasm-candle lane is in play
969
+ // (releaseHandoverBytes). A visitor who then picks the CPU lane needs
970
+ // them back: re-read the index, which is an immutable-cached fetch.
971
+ if (!state.indexBytes && !(caps && caps.dense_wasm)) {
972
+ state.indexLoaded = false;
973
+ }
738
974
  }
739
- }
740
- return "none";
741
- }
742
975
 
743
- async function loadWasmLane(lane) {
744
- const repo = lib.laneRepo(lane);
745
- const rev = lib.laneRevision(lane);
746
- const files = lib.laneFiles(lane);
747
- const loaded = {};
748
- for (const file of files) {
749
- loaded[file] = await getModelFile(repo, rev, file, lane.id);
750
- }
751
- postStatus("loading_model", { lane: laneSummary(lane) });
752
- const config = loaded["config.json"];
753
- const tokenizer = loaded["tokenizer.json"];
754
- const weights = files.map((f) => loaded[f]).find((b, i) => lib.isWeightsFile(files[i]));
755
- if (!config || !tokenizer || !weights) {
756
- throw new Error(`lane ${lane.id}: runtime.files must include config.json, tokenizer.json and a weights file`);
757
- }
758
- wasm_bindgen.init_dense_wasm(lane.id, config, tokenizer, weights);
759
- state.dense = { lane, kind: "wasm" };
760
- }
976
+ /**
977
+ * The sparse arm: nothing to fetch when the index embeds its vocabulary
978
+ * (`sparse_ready()` after init_index); otherwise tokenizer.json from the
979
+ * repo the manifest names, checked against `vocab_hash`.
980
+ */
981
+ async function ensureSparse() {
982
+ if (state.sparse) return;
983
+ const spec = state.manifest && state.manifest.sparse;
984
+ if (!spec) return;
985
+ if (state.degraded.some((d) => d.startsWith("sparse:"))) return;
986
+ try {
987
+ if (typeof wasm.sparse_ready === "function" && wasm.sparse_ready()) {
988
+ state.sparse = true;
989
+ return;
990
+ }
991
+ const repo = spec.tokenizer || spec.model;
992
+ const rev = spec.revision || "main";
993
+ let bytes = await getModelFile(repo, rev, "tokenizer.json", "sparse");
994
+ let ok = await lib.verifySha256(bytes, spec.vocab_hash, env.subtle);
995
+ if (!ok && bytes.fromCache) {
996
+ await idbDelete(lib.cacheKey(repo, rev, "tokenizer.json"));
997
+ bytes = await getModelFile(repo, rev, "tokenizer.json", "sparse", { noCache: true });
998
+ ok = await lib.verifySha256(bytes, spec.vocab_hash, env.subtle);
999
+ }
1000
+ if (!ok) {
1001
+ throw new Error("tokenizer.json SHA-256 does not match the index's vocab_hash");
1002
+ }
1003
+ wasm.init_sparse_tokenizer(bytes);
1004
+ state.sparse = true;
1005
+ state.sparseBytes = bytes;
1006
+ } catch (err) {
1007
+ if (isFatal(err)) throw err;
1008
+ console.warn("eddie: sparse arm unavailable", err);
1009
+ state.degraded.push(`sparse: ${describe(err)}`);
1010
+ }
1011
+ }
761
1012
 
762
- async function loadWebGpuLane(lane) {
763
- const runtime = lane.runtime;
764
- postStatus("loading_model", { lane: laneSummary(lane), file: "transformers.js" });
765
- const tf = await import(TRANSFORMERS_URL);
766
- if (state.db && tf.env) {
767
- // Keep the ONNX files in the same IndexedDB store as the wasm lanes: the
768
- // Cache API rejects multi-hundred-MB entries on some profiles, and one
769
- // store means one eviction policy (evictStaleFiles) and one consent check.
770
- tf.env.useCustomCache = true;
771
- tf.env.customCache = transformersCache();
772
- }
773
- const dtype = lib.pickDtype(runtime, state.gpu && state.gpu.hasF16);
774
- let last = 0;
775
- const extractor = await tf.pipeline("feature-extraction", runtime.repo, {
776
- device: "webgpu",
777
- dtype,
778
- revision: lib.laneRevision(lane),
779
- progress_callback: (p) => {
780
- if (!p || p.status !== "progress") return;
781
- const now = Date.now();
782
- if (now - last < PROGRESS_INTERVAL_MS && p.progress < 100) return;
783
- last = now;
784
- postStatus("downloading_model", {
1013
+ /** Returns "ok" (a lane is loaded), "consent" (waiting for the visitor) or "none". */
1014
+ async function ensureDense() {
1015
+ if (state.dense) return "ok";
1016
+ for (; state.laneIndex < state.candidates.length; state.laneIndex++) {
1017
+ const lane = state.candidates[state.laneIndex];
1018
+ try {
1019
+ const cached = await laneCached(lane);
1020
+ if (!cached && !state.consent[lane.id]) {
1021
+ postStatus("consent_required", Object.assign(await consentDetails(lane), {
1022
+ lanes: laneList(),
1023
+ hostSkippedLanes: state.hostSkipped.slice(),
1024
+ }));
1025
+ return "consent";
1026
+ }
1027
+ await ensureSidecar(lane, "chunks");
1028
+ if (lib.isWasmLane(lane)) {
1029
+ await loadWasmLane(lane);
1030
+ } else {
1031
+ await loadWebGpuLane(lane);
1032
+ }
1033
+ return "ok";
1034
+ } catch (err) {
1035
+ if (isFatal(err)) throw err;
1036
+ if (err && err.eddieTier) {
1037
+ // This host lacks the runtime, another tier has it: let the
1038
+ // widget move the search there and init again.
1039
+ postStatus("tier_required", { tier: String(err.eddieTier), lane: laneSummary(lane), message: describe(err) });
1040
+ return "tier";
1041
+ }
1042
+ console.warn(`eddie: dense lane ${lane.id} failed`, err);
1043
+ state.degraded.push(`dense: lane ${lane.id} failed: ${describe(err)}`);
1044
+ }
1045
+ }
1046
+ if (state.candidates.length === 0 && state.candidateReason && (state.manifest.dense || []).length > 0) {
1047
+ if (!state.degraded.some((d) => d.startsWith("dense:"))) {
1048
+ state.degraded.push(`dense: ${state.candidateReason}`);
1049
+ }
1050
+ }
1051
+ return "none";
1052
+ }
1053
+
1054
+ /** What the consent card needs: the lane, the real download size and where it comes from. */
1055
+ async function consentDetails(lane) {
1056
+ return {
785
1057
  lane: laneSummary(lane),
786
- file: p.file || p.name || "",
787
- progress: typeof p.progress === "number" ? p.progress / 100 : null,
788
- loaded: p.loaded,
789
- total: p.total,
1058
+ sizeBytes: await laneDownloadBytes(lane),
1059
+ origin: lib.laneOrigin(lane),
1060
+ sidecarBytes: lib.laneSidecarBytes(state.manifest, lane.id),
1061
+ saveData: !!(nav && nav.connection && nav.connection.saveData),
1062
+ };
1063
+ }
1064
+
1065
+ /**
1066
+ * Download size of a lane: the byte count the manifest declares
1067
+ * (`runtime.bytes` on a bundled lane) or the table estimate; for a
1068
+ * site-bundled wasm lane whose manifest predates `runtime.bytes`, the
1069
+ * Content-Length of its files (one HEAD per file, same origin), or null
1070
+ * when the server does not say.
1071
+ */
1072
+ async function laneDownloadBytes(lane) {
1073
+ const declared = lib.laneDeclaredBytes(lane);
1074
+ if (declared != null) return declared;
1075
+ // The table estimates describe the HuggingFace originals; a bundled
1076
+ // f16 copy is half that, so measure it rather than estimate.
1077
+ if (lib.laneOrigin(lane) !== "site" || !lib.isWasmLane(lane)) return lib.laneDownloadBytes(lane);
1078
+ if (state.siteSizes[lane.id] !== undefined) return state.siteSizes[lane.id];
1079
+ const doFetch = env.fetch || globalThis.fetch;
1080
+ let total = 0;
1081
+ try {
1082
+ for (const file of lib.laneFiles(lane)) {
1083
+ const res = await doFetch(laneFileUrl(lane, file), { method: "HEAD" });
1084
+ const len = res && res.ok && res.headers && res.headers.get ? Number(res.headers.get("Content-Length")) : NaN;
1085
+ if (!Number.isFinite(len) || len <= 0) {
1086
+ total = null;
1087
+ break;
1088
+ }
1089
+ total += len;
1090
+ }
1091
+ } catch (_) {
1092
+ total = null;
1093
+ }
1094
+ state.siteSizes[lane.id] = total;
1095
+ return total;
1096
+ }
1097
+
1098
+ /** `manifest.sidecars` file for (`scope`, lane), fetched next to the index and attached once. */
1099
+ async function ensureSidecar(lane, scope) {
1100
+ const side = lib.sidecarFor(state.manifest, scope, lane.id);
1101
+ if (!side) return false;
1102
+ if (state.attachedSidecars.includes(side.file)) return true;
1103
+ const url = lib.withVersion(new URL(side.file, state.indexUrl).href, state.version);
1104
+ let last = 0;
1105
+ postStatus("loading_sidecar", { lane: laneSummary(lane), file: side.file, progress: null, loaded: 0, total: side.bytes || null });
1106
+ const bytes = await lib.fetchBytes(url, {
1107
+ fetch: env.fetch,
1108
+ timeoutMs: 180000,
1109
+ onProgress: (loaded, total) => {
1110
+ const now = Date.now();
1111
+ if (total && now - last < PROGRESS_INTERVAL_MS && loaded !== total) return;
1112
+ last = now;
1113
+ postStatus("loading_sidecar", { lane: laneSummary(lane), file: side.file, progress: total ? loaded / total : null, loaded, total });
1114
+ },
790
1115
  });
791
- },
792
- });
793
- postStatus("loading_model", { lane: laneSummary(lane) });
794
- const pooling = runtime.pooling || "mean";
795
- const embed = async (text) => {
796
- const prefixed = (lane.query_prefix || "") + text;
797
- const out = await extractor(prefixed, { pooling, normalize: lane.normalize !== false });
798
- const data = out.data instanceof Float32Array ? out.data : Float32Array.from(out.data);
799
- if (typeof out.dispose === "function") out.dispose();
800
- if (data.length !== lane.dim) {
801
- throw new Error(`lane ${lane.id}: model produced ${data.length}-d vectors but the index stores ${lane.dim}-d`);
802
- }
803
- for (let i = 0; i < data.length; i++) {
804
- if (!Number.isFinite(data[i])) {
805
- throw new Error(`lane ${lane.id}: model produced a non-finite value at dimension ${i}`);
1116
+ if (typeof wasm.attach_sidecar !== "function") {
1117
+ throw new Error("this WASM build cannot attach sidecars (rebuild the widget)");
806
1118
  }
1119
+ wasm.attach_sidecar(bytes);
1120
+ state.attachedSidecars.push(side.file);
1121
+ if (handoverPossible()) state.sidecarBytes[side.file] = bytes;
1122
+ return true;
807
1123
  }
808
- return data;
809
- };
810
- await embed("warm up");
811
- state.dense = { lane, kind: "webgpu", embed, dtype };
812
- await idbMetaPut(webGpuMarker(lane, dtype), Date.now());
813
- }
814
1124
 
815
- function postReady() {
816
- self.postMessage({
817
- type: "ready",
818
- lanes: laneList(),
819
- lane: state.dense ? state.dense.lane.id : null,
820
- runtime: state.dense ? state.dense.kind : null,
821
- arms: { dense: !!state.dense, sparse: state.sparse, bm25: true },
822
- degraded: lib.filterDesignDegraded(unique(state.degraded)),
823
- manifest: manifestSummary(),
824
- });
825
- }
1125
+ function laneFileUrl(lane, file) {
1126
+ const site = lib.siteModelUrl(lane, file, state.indexUrl);
1127
+ if (site) return lib.withVersion(site, state.version);
1128
+ return lib.hfFileUrl(lib.laneRepo(lane), lib.laneRevision(lane), file);
1129
+ }
826
1130
 
827
- // -- cache check --------------------------------------------------------
828
-
829
- async function handleCacheCheck(msg) {
830
- try {
831
- await ensureWasm();
832
- await ensureIndex();
833
- await ensureDb();
834
- await ensureGpu();
835
- const lane = currentCandidate();
836
- const cached = lane ? await laneCached(lane) : true;
837
- self.postMessage({
838
- type: "cache_result",
839
- requestId: msg.requestId,
840
- cached,
841
- lane: lane ? laneSummary(lane) : null,
842
- sizeBytes: lane ? lib.laneDownloadBytes(lane) : 0,
843
- });
844
- } catch (err) {
845
- postError(msg.requestId, describe(err), isFatal(err));
846
- }
847
- }
1131
+ async function loadWasmLane(lane) {
1132
+ const repo = lib.laneRepo(lane);
1133
+ const rev = lib.laneRevision(lane);
1134
+ const files = lib.laneFiles(lane);
1135
+ const loaded = {};
1136
+ for (const file of files) {
1137
+ loaded[file] = await getModelFile(repo, rev, file, lane.id, { url: laneFileUrl(lane, file), name: lib.laneFileName(lane, file) });
1138
+ }
1139
+ await ensureDenseWasm();
1140
+ postStatus("loading_model", { lane: laneSummary(lane) });
1141
+ const config = loaded["config.json"];
1142
+ const tokenizer = loaded["tokenizer.json"];
1143
+ const weightsName = files.find((f) => lib.isWeightsFile(f));
1144
+ const weights = weightsName ? loaded[weightsName] : undefined;
1145
+ if (!config || !tokenizer || !weights) {
1146
+ throw new Error(`lane ${lane.id}: runtime.files must include config.json, tokenizer.json and a weights file`);
1147
+ }
1148
+ wasm.init_dense_wasm(lane.id, config, tokenizer, weights);
1149
+ state.dense = { lane, kind: "wasm" };
1150
+ }
1151
+
1152
+ async function loadWebGpuLane(lane) {
1153
+ const runtime = lane.runtime;
1154
+ postStatus("loading_model", { lane: laneSummary(lane), file: "transformers.js" });
1155
+ const tf = await env.loadTransformers();
1156
+ if (state.db && tf.env) {
1157
+ // Keep the ONNX files in the same IndexedDB store as the wasm lanes: the
1158
+ // Cache API rejects multi-hundred-MB entries on some profiles, and one
1159
+ // store means one eviction policy (evictStaleFiles) and one consent check.
1160
+ tf.env.useCustomCache = true;
1161
+ tf.env.customCache = transformersCache();
1162
+ }
1163
+ if (tf.env) configureModelSource(tf.env, lane);
1164
+ if (env.configureTransformers) env.configureTransformers(tf);
1165
+ const dtype = lib.pickDtype(runtime, state.gpu && state.gpu.hasF16);
1166
+ let last = 0;
1167
+ const extractor = await tf.pipeline("feature-extraction", runtime.repo, {
1168
+ device: "webgpu",
1169
+ dtype,
1170
+ revision: lib.laneRevision(lane),
1171
+ progress_callback: (p) => {
1172
+ if (!p || p.status !== "progress") return;
1173
+ const now = Date.now();
1174
+ if (now - last < PROGRESS_INTERVAL_MS && p.progress < 100) return;
1175
+ last = now;
1176
+ postStatus("downloading_model", {
1177
+ lane: laneSummary(lane),
1178
+ file: p.file || p.name || "",
1179
+ progress: typeof p.progress === "number" ? p.progress / 100 : null,
1180
+ loaded: p.loaded,
1181
+ total: p.total,
1182
+ });
1183
+ },
1184
+ });
1185
+ postStatus("loading_model", { lane: laneSummary(lane) });
1186
+ const pooling = runtime.pooling || "mean";
1187
+ const embed = async (text) => {
1188
+ const prefixed = (lane.query_prefix || "") + text;
1189
+ const out = await extractor(prefixed, { pooling, normalize: lane.normalize !== false });
1190
+ const data = out.data instanceof Float32Array ? out.data : Float32Array.from(out.data);
1191
+ if (typeof out.dispose === "function") out.dispose();
1192
+ if (data.length !== lane.dim) {
1193
+ throw new Error(`lane ${lane.id}: model produced ${data.length}-d vectors but the index stores ${lane.dim}-d`);
1194
+ }
1195
+ for (let i = 0; i < data.length; i++) {
1196
+ if (!Number.isFinite(data[i])) {
1197
+ throw new Error(`lane ${lane.id}: model produced a non-finite value at dimension ${i}`);
1198
+ }
1199
+ }
1200
+ return data;
1201
+ };
1202
+ await embed("warm up");
1203
+ state.dense = { lane, kind: "webgpu", embed, dtype };
1204
+ await idbMetaPut(webGpuMarker(lane, dtype), Date.now());
1205
+ }
848
1206
 
849
- async function laneCached(lane) {
850
- if (lib.isWasmLane(lane)) {
851
- if (!state.db) return false;
852
- const repo = lib.laneRepo(lane);
853
- const rev = lib.laneRevision(lane);
854
- for (const file of lib.laneFiles(lane)) {
855
- if (!(await idbHas(lib.cacheKey(repo, rev, file)))) return false;
1207
+ /**
1208
+ * Point transformers.js at the site-hosted copy of a webgpu-onnx repo
1209
+ * when the manifest gives one (`runtime.base_url`, relative to the index
1210
+ * URL; the directory mirrors the repo's file layout: config.json,
1211
+ * tokenizer.json, onnx/model_q4.onnx ...). transformers.js joins
1212
+ * remoteHost, remotePathTemplate and the file name with "/"; "." as the
1213
+ * template keeps the model id out of the path and the URL parser folds
1214
+ * the "./" away. Without a base_url the defaults (huggingface.co) are
1215
+ * restored, since these settings are global to the module.
1216
+ */
1217
+ function configureModelSource(tfEnv, lane) {
1218
+ const base = lib.siteModelUrl(lane, "", state.indexUrl);
1219
+ if (base) {
1220
+ tfEnv.allowRemoteModels = true;
1221
+ tfEnv.remoteHost = base;
1222
+ tfEnv.remotePathTemplate = ".";
1223
+ } else {
1224
+ tfEnv.remoteHost = "https://huggingface.co/";
1225
+ tfEnv.remotePathTemplate = "{model}/resolve/{revision}/";
1226
+ }
856
1227
  }
857
- return true;
858
- }
859
- const dtype = lib.pickDtype(lane.runtime, state.gpu && state.gpu.hasF16);
860
- return !!(await idbMetaGet(webGpuMarker(lane, dtype)));
861
- }
862
1228
 
863
- function webGpuMarker(lane, dtype) {
864
- return `webgpu:${lane.runtime.repo}@${lib.laneRevision(lane)}#${dtype}`;
865
- }
1229
+ function readyMessage() {
1230
+ return {
1231
+ type: "ready",
1232
+ lanes: laneList(),
1233
+ lane: state.dense ? state.dense.lane.id : null,
1234
+ runtime: state.dense ? state.dense.kind : null,
1235
+ arms: { dense: !!state.dense, sparse: state.sparse, bm25: true },
1236
+ degraded: reportedDegraded(state.degraded),
1237
+ manifest: manifestSummary(),
1238
+ hostSkippedLanes: state.hostSkipped.slice(),
1239
+ wasm: caps && caps.dense_wasm ? "dense" : "lite",
1240
+ };
1241
+ }
866
1242
 
867
- // -- queries ------------------------------------------------------------
868
-
869
- /**
870
- * Query vector from the WebGPU lane, or nothing (the WASM lane embeds inside
871
- * search()). An embedding failure degrades the dense arm for this query
872
- * instead of failing the search; `note` carries the reason.
873
- */
874
- async function queryVector(query, mode) {
875
- if (!state.dense || state.dense.kind !== "webgpu") return { laneId: null, vec: null, note: null };
876
- if (mode !== "hybrid" && mode !== "dense") return { laneId: null, vec: null, note: null };
877
- try {
878
- const vec = await state.dense.embed(query);
879
- return { laneId: state.dense.lane.id, vec, note: null };
880
- } catch (err) {
881
- console.warn(`eddie: dense lane ${state.dense.lane.id} query embedding failed`, err);
882
- return { laneId: null, vec: null, note: `dense: embedding failed for lane ${state.dense.lane.id}: ${describe(err)}` };
883
- }
884
- }
1243
+ function postReady() {
1244
+ post(readyMessage());
1245
+ }
885
1246
 
886
- async function handleSearch(msg) {
887
- const requestId = msg.requestId;
888
- try {
889
- requireIndex();
890
- const query = String(msg.query || "").trim();
891
- if (!query) throw new Error("search: empty query");
892
- const mode = msg.mode || "hybrid";
893
- const topK = Number(msg.topK) > 0 ? Number(msg.topK) : 8;
894
- const { laneId, vec, note } = await queryVector(query, mode);
895
- const res = JSON.parse(wasm_bindgen.search(query, topK, mode, laneId, vec));
896
- if (note) res.degraded = (res.degraded || []).concat([note]);
897
- const results = res.results || [];
898
- if (msg.evidence) {
899
- for (const r of results) {
900
- try {
901
- r.text = JSON.parse(wasm_bindgen.chunk(r.chunk)).text;
902
- } catch (_) {
903
- r.text = r.snippet;
1247
+ // -- cache check --------------------------------------------------------
1248
+
1249
+ async function handleCacheCheck(msg, reply) {
1250
+ try {
1251
+ if (state.phase === "dead") throw fatal("search engine crashed; reload to retry");
1252
+ if (msg.indexUrl) state.indexUrl = String(msg.indexUrl);
1253
+ if (msg.baseUrl != null) state.baseUrl = String(msg.baseUrl);
1254
+ if (msg.version != null) state.version = msg.version ? String(msg.version) : null;
1255
+ if (msg.denseRuntime) state.denseRuntime = String(msg.denseRuntime);
1256
+ if (msg.laneId !== undefined) state.laneId = msg.laneId ? String(msg.laneId) : null;
1257
+ await ensureWasm();
1258
+ await ensureIndex();
1259
+ await ensureDb();
1260
+ await ensureGpu();
1261
+ chooseLanes();
1262
+ const lane = currentCandidate();
1263
+ const cached = lane ? await laneCached(lane) : true;
1264
+ reply({
1265
+ type: "cache_result",
1266
+ requestId: msg.requestId,
1267
+ cached,
1268
+ lanes: laneList(),
1269
+ lane: lane ? laneSummary(lane) : null,
1270
+ sizeBytes: lane ? await laneDownloadBytes(lane) : 0,
1271
+ origin: lane ? lib.laneOrigin(lane) : null,
1272
+ sidecarBytes: lane ? lib.laneSidecarBytes(state.manifest, lane.id) : 0,
1273
+ hostSkippedLanes: state.hostSkipped.slice(),
1274
+ phase: state.phase,
1275
+ });
1276
+ } catch (err) {
1277
+ if (isFatal(err)) state.phase = "dead";
1278
+ postError(reply, msg.requestId, describe(err), isFatal(err));
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Delete every cached model file. The engine keeps whatever it has
1284
+ * already loaded into memory for this session; the files come back on the
1285
+ * next visit that needs them.
1286
+ */
1287
+ async function handleCacheClear(msg, reply) {
1288
+ let freedBytes = 0;
1289
+ try {
1290
+ await ensureDb();
1291
+ const keys = (await idbRequest(IDB_FILES, "readonly", (s) => s.getAllKeys())) || [];
1292
+ for (const key of keys) {
1293
+ const value = await idbGet(key);
1294
+ freedBytes += value ? (typeof value.size === "number" ? value.size : value.byteLength || 0) : 0;
1295
+ await idbDelete(key);
904
1296
  }
1297
+ const metaKeys = (await idbRequest(IDB_META, "readonly", (s) => s.getAllKeys())) || [];
1298
+ for (const key of metaKeys) {
1299
+ await idbRequest(IDB_META, "readwrite", (s) => s.delete(key));
1300
+ }
1301
+ } catch (err) {
1302
+ console.warn("eddie: could not clear the model cache", err);
905
1303
  }
1304
+ reply({ type: "cache_clear_result", requestId: msg.requestId, freedBytes });
906
1305
  }
907
- let qa = undefined;
908
- if (msg.qa && Number(msg.qa) > 0 && hasSection("qa") && (vec || !laneId)) {
909
- qa = JSON.parse(wasm_bindgen.qa_lookup(query, laneId, vec, Number(msg.qa)));
910
- }
911
- self.postMessage({
912
- type: "search_result",
913
- requestId,
914
- results,
915
- arms: res.arms,
916
- degraded: lib.filterDesignDegraded(unique((res.degraded || []).concat(state.degraded))),
917
- mode: res.mode,
918
- lane: res.dense_lane || null,
919
- qa,
920
- });
921
- } catch (err) {
922
- failRequest(requestId, err);
923
- }
924
- }
925
1306
 
926
- function handleLookup(msg, type, key, fn) {
927
- try {
928
- requireIndex();
929
- self.postMessage({ type, requestId: msg.requestId, [key]: JSON.parse(fn()) });
930
- } catch (err) {
931
- failRequest(msg.requestId, err);
932
- }
933
- }
1307
+ async function laneCached(lane) {
1308
+ if (lib.isWasmLane(lane)) {
1309
+ if (!state.db) return false;
1310
+ const repo = lib.laneRepo(lane);
1311
+ const rev = lib.laneRevision(lane);
1312
+ for (const file of lib.laneFiles(lane)) {
1313
+ if (!(await idbHas(lib.cacheKey(repo, rev, lib.laneFileName(lane, file))))) return false;
1314
+ }
1315
+ return true;
1316
+ }
1317
+ const dtype = lib.pickDtype(lane.runtime, state.gpu && state.gpu.hasF16);
1318
+ return !!(await idbMetaGet(webGpuMarker(lane, dtype)));
1319
+ }
934
1320
 
935
- async function handleQa(msg) {
936
- try {
937
- requireIndex();
938
- const query = String(msg.query || "").trim();
939
- const k = Number(msg.k) > 0 ? Number(msg.k) : 3;
940
- if (!query || !hasSection("qa")) {
941
- self.postMessage({ type: "qa_result", requestId: msg.requestId, hits: [] });
942
- return;
943
- }
944
- const { laneId, vec } = await queryVector(query, "dense");
945
- const hits = JSON.parse(wasm_bindgen.qa_lookup(query, laneId, vec, k));
946
- self.postMessage({ type: "qa_result", requestId: msg.requestId, hits });
947
- } catch (err) {
948
- failRequest(msg.requestId, err);
949
- }
950
- }
1321
+ function webGpuMarker(lane, dtype) {
1322
+ return `webgpu:${lane.runtime.repo}@${lib.laneRevision(lane)}#${dtype}`;
1323
+ }
951
1324
 
952
- function failRequest(requestId, err) {
953
- if (isFatal(err)) {
954
- // A trapped panic leaves the WASM instance unusable; never re-enter it.
955
- state.phase = "dead";
956
- postError(requestId, "search engine crashed: " + describe(err), true);
957
- return;
958
- }
959
- postError(requestId, describe(err), false);
960
- }
1325
+ // -- queries ------------------------------------------------------------
961
1326
 
962
- function requireIndex() {
963
- if (state.phase === "dead") throw fatal("search engine crashed; reload to retry");
964
- if (!state.indexLoaded) throw new Error("index not loaded yet");
965
- }
1327
+ /**
1328
+ * Query vector from the WebGPU lane, or nothing (the WASM lane embeds inside
1329
+ * search()). An embedding failure degrades the dense arm for this query
1330
+ * instead of failing the search; `note` carries the reason.
1331
+ */
1332
+ async function queryVector(query, mode) {
1333
+ if (!state.dense || state.dense.kind !== "webgpu") return { laneId: null, vec: null, note: null };
1334
+ if (mode !== "hybrid" && mode !== "dense") return { laneId: null, vec: null, note: null };
1335
+ try {
1336
+ const vec = await state.dense.embed(query);
1337
+ return { laneId: state.dense.lane.id, vec, note: null };
1338
+ } catch (err) {
1339
+ console.warn(`eddie: dense lane ${state.dense.lane.id} query embedding failed`, err);
1340
+ return { laneId: null, vec: null, note: `dense: embedding failed for lane ${state.dense.lane.id}: ${describe(err)}` };
1341
+ }
1342
+ }
966
1343
 
967
- function hasSection(name) {
968
- return !!(state.manifest && Array.isArray(state.manifest.sections) && state.manifest.sections.includes(name));
969
- }
1344
+ /**
1345
+ * The qa-scope vectors of the active lane live in a sidecar the first QA
1346
+ * lookup fetches (never before: most searches never show the FAQ card).
1347
+ * A failed fetch leaves the lexical ranking in place.
1348
+ */
1349
+ async function ensureQaVectors() {
1350
+ if (!state.dense || !hasSection("qa")) return;
1351
+ try {
1352
+ await ensureSidecar(state.dense.lane, "qa");
1353
+ } catch (err) {
1354
+ if (isFatal(err)) throw err;
1355
+ console.warn("eddie: qa sidecar unavailable; FAQ ranking is lexical", err);
1356
+ }
1357
+ }
1358
+
1359
+ async function handleSearch(msg, reply) {
1360
+ const requestId = msg.requestId;
1361
+ try {
1362
+ requireIndex();
1363
+ const query = String(msg.query || "").trim();
1364
+ if (!query) throw new Error("search: empty query");
1365
+ const mode = msg.mode || "hybrid";
1366
+ const topK = Number(msg.topK) > 0 ? Number(msg.topK) : 8;
1367
+ const { laneId, vec, note } = await queryVector(query, mode);
1368
+ const res = JSON.parse(wasm.search(query, topK, mode, laneId, vec));
1369
+ if (note) res.degraded = (res.degraded || []).concat([note]);
1370
+ const results = res.results || [];
1371
+ if (msg.evidence) {
1372
+ for (const r of results) {
1373
+ try {
1374
+ r.text = JSON.parse(wasm.chunk(r.chunk)).text;
1375
+ } catch (_) {
1376
+ r.text = r.snippet;
1377
+ }
1378
+ }
1379
+ }
1380
+ let qa = undefined;
1381
+ if (msg.qa && Number(msg.qa) > 0 && hasSection("qa") && (vec || !laneId)) {
1382
+ await ensureQaVectors();
1383
+ qa = JSON.parse(wasm.qa_lookup(query, laneId, vec, Number(msg.qa)));
1384
+ }
1385
+ reply({
1386
+ type: "search_result",
1387
+ requestId,
1388
+ results,
1389
+ arms: res.arms,
1390
+ degraded: reportedDegraded((res.degraded || []).concat(state.degraded)),
1391
+ mode: res.mode,
1392
+ lane: res.dense_lane || null,
1393
+ qa,
1394
+ });
1395
+ } catch (err) {
1396
+ failRequest(reply, requestId, err);
1397
+ }
1398
+ }
1399
+
1400
+ function handleLookup(msg, reply, type, key, fn) {
1401
+ try {
1402
+ requireIndex();
1403
+ reply({ type, requestId: msg.requestId, [key]: JSON.parse(fn()) });
1404
+ } catch (err) {
1405
+ failRequest(reply, msg.requestId, err);
1406
+ }
1407
+ return Promise.resolve();
1408
+ }
1409
+
1410
+ async function handleQa(msg, reply) {
1411
+ try {
1412
+ requireIndex();
1413
+ const query = String(msg.query || "").trim();
1414
+ const k = Number(msg.k) > 0 ? Number(msg.k) : 3;
1415
+ if (!query || !hasSection("qa")) {
1416
+ reply({ type: "qa_result", requestId: msg.requestId, hits: [] });
1417
+ return;
1418
+ }
1419
+ await ensureQaVectors();
1420
+ const { laneId, vec } = await queryVector(query, "dense");
1421
+ const hits = JSON.parse(wasm.qa_lookup(query, laneId, vec, k));
1422
+ reply({ type: "qa_result", requestId: msg.requestId, hits });
1423
+ } catch (err) {
1424
+ failRequest(reply, msg.requestId, err);
1425
+ }
1426
+ }
1427
+
1428
+ function failRequest(reply, requestId, err) {
1429
+ if (isFatal(err)) {
1430
+ // A trapped panic leaves the WASM instance unusable; never re-enter it.
1431
+ state.phase = "dead";
1432
+ postError(reply, requestId, "search engine crashed: " + describe(err), true);
1433
+ return;
1434
+ }
1435
+ postError(reply, requestId, describe(err), false);
1436
+ }
970
1437
 
971
- // -- model files --------------------------------------------------------
1438
+ function requireIndex() {
1439
+ if (state.phase === "dead") throw fatal("search engine crashed; reload to retry");
1440
+ if (!state.indexLoaded) throw new Error(NOT_LOADED);
1441
+ }
972
1442
 
973
- async function getModelFile(repo, rev, file, laneId, opts) {
974
- const key = lib.cacheKey(repo, rev, file);
975
- if (!(opts && opts.noCache)) {
976
- const cached = await idbGet(key);
977
- if (cached) {
978
- const bytes = new Uint8Array(cached);
979
- bytes.fromCache = true;
1443
+ function hasSection(name) {
1444
+ return !!(state.manifest && Array.isArray(state.manifest.sections) && state.manifest.sections.includes(name));
1445
+ }
1446
+
1447
+ // -- model files --------------------------------------------------------
1448
+
1449
+ /**
1450
+ * A model file from the IndexedDB cache or the network. `opts.url` and
1451
+ * `opts.name` override the HuggingFace URL and cache name (site-bundled
1452
+ * files); `opts.noCache` skips the cache read.
1453
+ */
1454
+ async function getModelFile(repo, rev, file, laneId, opts) {
1455
+ const o = opts || {};
1456
+ const key = lib.cacheKey(repo, rev, o.name || file);
1457
+ if (!o.noCache) {
1458
+ const cached = await idbGet(key);
1459
+ if (cached) {
1460
+ const bytes = new Uint8Array(cached);
1461
+ bytes.fromCache = true;
1462
+ return bytes;
1463
+ }
1464
+ }
1465
+ const url = o.url || lib.hfFileUrl(repo, rev, file);
1466
+ let last = 0;
1467
+ postStatus("downloading_model", { lane: laneId, file, progress: null, loaded: 0, total: null });
1468
+ const bytes = await lib.fetchBytes(url, {
1469
+ fetch: env.fetch,
1470
+ timeoutMs: lib.timeoutForFile(file),
1471
+ retries: 1,
1472
+ backoffMs: 1500,
1473
+ onProgress: (loaded, total) => {
1474
+ const now = Date.now();
1475
+ if (now - last < PROGRESS_INTERVAL_MS && !(total && loaded === total)) return;
1476
+ last = now;
1477
+ postStatus("downloading_model", { lane: laneId, file, progress: total ? loaded / total : null, loaded, total });
1478
+ },
1479
+ });
1480
+ await idbPut(key, bytes.buffer);
980
1481
  return bytes;
981
1482
  }
982
- }
983
- const url = lib.hfFileUrl(repo, rev, file);
984
- let last = 0;
985
- postStatus("downloading_model", { lane: laneId, file, progress: null, loaded: 0, total: null });
986
- const bytes = await lib.fetchBytes(url, {
987
- timeoutMs: lib.timeoutForFile(file),
988
- retries: 1,
989
- backoffMs: 1500,
990
- onProgress: (loaded, total) => {
991
- const now = Date.now();
992
- if (now - last < PROGRESS_INTERVAL_MS && !(total && loaded === total)) return;
993
- last = now;
994
- postStatus("downloading_model", { lane: laneId, file, progress: total ? loaded / total : null, loaded, total });
995
- },
996
- });
997
- await idbPut(key, bytes.buffer);
998
- return bytes;
999
- }
1000
1483
 
1001
- // -- IndexedDB (best effort: any failure only logs) --------------------
1002
-
1003
- async function ensureDb() {
1004
- if (state.db !== undefined) return;
1005
- state.db = null;
1006
- if (typeof indexedDB === "undefined") return;
1007
- try {
1008
- state.db = await new Promise((resolve, reject) => {
1009
- const req = indexedDB.open(IDB_NAME, IDB_VERSION);
1010
- req.onupgradeneeded = () => {
1011
- const db = req.result;
1012
- if (!db.objectStoreNames.contains(IDB_FILES)) db.createObjectStore(IDB_FILES);
1013
- if (!db.objectStoreNames.contains(IDB_META)) db.createObjectStore(IDB_META);
1484
+ // -- IndexedDB (best effort: any failure only logs) --------------------
1485
+
1486
+ async function ensureDb() {
1487
+ if (state.db !== undefined) return;
1488
+ state.db = null;
1489
+ if (!idb) return;
1490
+ try {
1491
+ state.db = await new Promise((resolve, reject) => {
1492
+ const req = idb.open(IDB_NAME, IDB_VERSION);
1493
+ req.onupgradeneeded = () => {
1494
+ const db = req.result;
1495
+ if (!db.objectStoreNames.contains(IDB_FILES)) db.createObjectStore(IDB_FILES);
1496
+ if (!db.objectStoreNames.contains(IDB_META)) db.createObjectStore(IDB_META);
1497
+ };
1498
+ req.onsuccess = () => resolve(req.result);
1499
+ req.onerror = () => reject(req.error || new Error("indexedDB.open failed"));
1500
+ req.onblocked = () => reject(new Error("indexedDB.open blocked"));
1501
+ });
1502
+ } catch (err) {
1503
+ console.warn("eddie: model cache unavailable; files will be re-downloaded next visit", err);
1504
+ state.db = null;
1505
+ }
1506
+ }
1507
+
1508
+ function idbRequest(store, mode, fn) {
1509
+ if (!state.db) return Promise.resolve(undefined);
1510
+ return new Promise((resolve) => {
1511
+ try {
1512
+ const tx = state.db.transaction(store, mode);
1513
+ const req = fn(tx.objectStore(store));
1514
+ req.onsuccess = () => resolve(req.result);
1515
+ req.onerror = () => {
1516
+ console.warn("eddie: model cache operation failed", req.error);
1517
+ resolve(undefined);
1518
+ };
1519
+ } catch (err) {
1520
+ console.warn("eddie: model cache operation failed", err);
1521
+ resolve(undefined);
1522
+ }
1523
+ });
1524
+ }
1525
+
1526
+ const idbGet = (key) => idbRequest(IDB_FILES, "readonly", (s) => s.get(key));
1527
+ const idbHas = async (key) => (await idbRequest(IDB_FILES, "readonly", (s) => s.getKey(key))) !== undefined;
1528
+ const idbPut = (key, value) => idbRequest(IDB_FILES, "readwrite", (s) => s.put(value, key));
1529
+ const idbDelete = (key) => idbRequest(IDB_FILES, "readwrite", (s) => s.delete(key));
1530
+ const idbMetaGet = (key) => idbRequest(IDB_META, "readonly", (s) => s.get(key));
1531
+ const idbMetaPut = (key, value) => idbRequest(IDB_META, "readwrite", (s) => s.put(value, key));
1532
+
1533
+ /**
1534
+ * transformers.js cache (`env.customCache`) over the model-file store.
1535
+ * transformers.js calls `match`/`put` with string keys: the HuggingFace URL
1536
+ * for remote files (mapped onto the repo@revision/file scheme) or a local
1537
+ * model path (never stored, so `match` misses and the URL key is tried next).
1538
+ */
1539
+ function transformersCache() {
1540
+ const keyOf = (req) => {
1541
+ const url = typeof req === "string" ? req : req && req.url ? req.url : String(req);
1542
+ return lib.cacheKeyFromUrl(url) || "url:" + url;
1014
1543
  };
1015
- req.onsuccess = () => resolve(req.result);
1016
- req.onerror = () => reject(req.error || new Error("indexedDB.open failed"));
1017
- req.onblocked = () => reject(new Error("indexedDB.open blocked"));
1018
- });
1019
- } catch (err) {
1020
- console.warn("eddie: model cache unavailable; files will be re-downloaded next visit", err);
1021
- state.db = null;
1022
- }
1023
- }
1544
+ return {
1545
+ async match(req) {
1546
+ const stored = await idbGet(keyOf(req));
1547
+ if (!stored) return undefined;
1548
+ const size = typeof stored.size === "number" ? stored.size : stored.byteLength;
1549
+ return new Response(stored, { headers: { "Content-Length": String(size) } });
1550
+ },
1551
+ async put(req, response) {
1552
+ const blob = await response.blob();
1553
+ await idbPut(keyOf(req), blob);
1554
+ },
1555
+ };
1556
+ }
1557
+
1558
+ /** Delete cached files that belong to models this index no longer references. */
1559
+ async function evictStaleFiles() {
1560
+ if (!state.db || !state.manifest) return;
1561
+ const keep = ["url:"];
1562
+ for (const lane of state.manifest.dense || []) {
1563
+ keep.push(`${lib.laneRepo(lane)}@${lib.laneRevision(lane)}/`);
1564
+ }
1565
+ if (state.manifest.sparse) {
1566
+ const s = state.manifest.sparse;
1567
+ keep.push(`${s.tokenizer || s.model}@${s.revision || "main"}/`);
1568
+ }
1569
+ const keys = await idbRequest(IDB_FILES, "readonly", (s) => s.getAllKeys());
1570
+ for (const key of keys || []) {
1571
+ if (typeof key === "string" && !keep.some((p) => key.startsWith(p))) {
1572
+ console.info("eddie: evicting cached model file", key);
1573
+ await idbDelete(key);
1574
+ }
1575
+ }
1576
+ }
1577
+
1578
+ // -- helpers ------------------------------------------------------------
1024
1579
 
1025
- function idbRequest(store, mode, fn) {
1026
- if (!state.db) return Promise.resolve(undefined);
1027
- return new Promise((resolve) => {
1028
- try {
1029
- const tx = state.db.transaction(store, mode);
1030
- const req = fn(tx.objectStore(store));
1031
- req.onsuccess = () => resolve(req.result);
1032
- req.onerror = () => {
1033
- console.warn("eddie: model cache operation failed", req.error);
1034
- resolve(undefined);
1580
+ function currentCandidate() {
1581
+ return state.candidates[state.laneIndex] || null;
1582
+ }
1583
+
1584
+ function laneSummary(lane) {
1585
+ return {
1586
+ id: lane.id,
1587
+ model: lane.model,
1588
+ kind: lane.runtime ? lane.runtime.kind : null,
1589
+ repo: lib.laneRepo(lane),
1590
+ dim: lane.dim,
1591
+ origin: lib.laneOrigin(lane),
1035
1592
  };
1036
- } catch (err) {
1037
- console.warn("eddie: model cache operation failed", err);
1038
- resolve(undefined);
1039
1593
  }
1040
- });
1041
- }
1042
1594
 
1043
- const idbGet = (key) => idbRequest(IDB_FILES, "readonly", (s) => s.get(key));
1044
- const idbHas = async (key) => (await idbRequest(IDB_FILES, "readonly", (s) => s.getKey(key))) !== undefined;
1045
- const idbPut = (key, value) => idbRequest(IDB_FILES, "readwrite", (s) => s.put(value, key));
1046
- const idbDelete = (key) => idbRequest(IDB_FILES, "readwrite", (s) => s.delete(key));
1047
- const idbMetaGet = (key) => idbRequest(IDB_META, "readonly", (s) => s.get(key));
1048
- const idbMetaPut = (key, value) => idbRequest(IDB_META, "readwrite", (s) => s.put(value, key));
1049
-
1050
- /**
1051
- * transformers.js cache (`env.customCache`) over the model-file store.
1052
- * transformers.js calls `match`/`put` with string keys: the HuggingFace URL
1053
- * for remote files (mapped onto the repo@revision/file scheme) or a local
1054
- * model path (never stored, so `match` misses and the URL key is tried next).
1055
- */
1056
- function transformersCache() {
1057
- const keyOf = (req) => {
1058
- const url = typeof req === "string" ? req : req && req.url ? req.url : String(req);
1059
- return lib.cacheKeyFromUrl(url) || "url:" + url;
1060
- };
1061
- return {
1062
- async match(req) {
1063
- const stored = await idbGet(keyOf(req));
1064
- if (!stored) return undefined;
1065
- const size = typeof stored.size === "number" ? stored.size : stored.byteLength;
1066
- return new Response(stored, { headers: { "Content-Length": String(size) } });
1067
- },
1068
- async put(req, response) {
1069
- const blob = await response.blob();
1070
- await idbPut(keyOf(req), blob);
1071
- },
1072
- };
1073
- }
1595
+ function laneList() {
1596
+ return (state.manifest && state.manifest.dense ? state.manifest.dense : []).map(laneSummary);
1597
+ }
1074
1598
 
1075
- /** Delete cached files that belong to models this index no longer references. */
1076
- async function evictStaleFiles() {
1077
- if (!state.db || !state.manifest) return;
1078
- const keep = ["url:"];
1079
- for (const lane of state.manifest.dense || []) {
1080
- keep.push(`${lib.laneRepo(lane)}@${lib.laneRevision(lane)}/`);
1599
+ function manifestSummary() {
1600
+ const m = state.manifest || {};
1601
+ return {
1602
+ format: m.format,
1603
+ eddie: m.eddie,
1604
+ chunks: m.chunks,
1605
+ pages: m.pages,
1606
+ sections: m.sections || [],
1607
+ sparse: !!m.sparse,
1608
+ sidecars: Array.isArray(m.sidecars) ? m.sidecars.length : 0,
1609
+ };
1610
+ }
1611
+
1612
+ function postStatus(stateName, extra) {
1613
+ post(Object.assign({ type: "status", state: stateName }, extra || {}));
1614
+ }
1615
+
1616
+ function postError(reply, requestId, message, isFatalErr, isUnsupportedErr) {
1617
+ reply({ type: "error", requestId: requestId == null ? undefined : requestId, message, fatal: !!isFatalErr, unsupported: !!isUnsupportedErr });
1618
+ }
1619
+
1620
+ return {
1621
+ handle,
1622
+ state: snapshot,
1623
+ readyMessage,
1624
+ get phase() {
1625
+ return state.phase;
1626
+ },
1627
+ };
1081
1628
  }
1082
- if (state.manifest.sparse) {
1083
- const s = state.manifest.sparse;
1084
- keep.push(`${s.tokenizer || s.model}@${s.revision || "main"}/`);
1629
+
1630
+ function unique(list) {
1631
+ return Array.from(new Set(list));
1085
1632
  }
1086
- const keys = await idbRequest(IDB_FILES, "readonly", (s) => s.getAllKeys());
1087
- for (const key of keys || []) {
1088
- if (typeof key === "string" && !keep.some((p) => key.startsWith(p))) {
1089
- console.info("eddie: evicting cached model file", key);
1090
- await idbDelete(key);
1091
- }
1633
+
1634
+ function describe(err) {
1635
+ if (err == null) return "unknown error";
1636
+ if (typeof err === "string") return err;
1637
+ if (err.message) return err.message;
1638
+ return String(err);
1092
1639
  }
1093
- }
1094
1640
 
1095
- // -- helpers ------------------------------------------------------------
1641
+ function unsupported(message) {
1642
+ const e = new Error(message);
1643
+ e.eddieUnsupported = true;
1644
+ e.eddieFatal = true;
1645
+ return e;
1646
+ }
1096
1647
 
1097
- function currentCandidate() {
1098
- return state.candidates[state.laneIndex] || null;
1099
- }
1648
+ function fatal(message) {
1649
+ const e = new Error(message);
1650
+ e.eddieFatal = true;
1651
+ return e;
1652
+ }
1100
1653
 
1101
- function laneSummary(lane) {
1102
- return {
1103
- id: lane.id,
1104
- model: lane.model,
1105
- kind: lane.runtime ? lane.runtime.kind : null,
1106
- repo: lib.laneRepo(lane),
1107
- dim: lane.dim,
1108
- };
1109
- }
1654
+ function isUnsupported(err) {
1655
+ return !!(err && err.eddieUnsupported);
1656
+ }
1110
1657
 
1111
- function laneList() {
1112
- return (state.manifest && state.manifest.dense ? state.manifest.dense : []).map(laneSummary);
1113
- }
1658
+ function isFatal(err) {
1659
+ if (!err) return false;
1660
+ if (err.eddieFatal) return true;
1661
+ return typeof WebAssembly === "object" && err instanceof WebAssembly.RuntimeError;
1662
+ }
1114
1663
 
1115
- function manifestSummary() {
1116
- const m = state.manifest || {};
1117
- return {
1118
- format: m.format,
1119
- eddie: m.eddie,
1120
- chunks: m.chunks,
1121
- pages: m.pages,
1122
- sections: m.sections || [],
1123
- sparse: !!m.sparse,
1124
- };
1125
- }
1664
+ /** True for the engine's "not initialised" replies (the client re-runs init). */
1665
+ function isNotLoadedMessage(message) {
1666
+ return typeof message === "string" && message.indexOf(NOT_LOADED) === 0;
1667
+ }
1126
1668
 
1127
- function unique(list) {
1128
- return Array.from(new Set(list));
1129
- }
1669
+ return { createSearchEngine, wasmCapabilities, NOT_LOADED, isNotLoadedMessage };
1670
+ });
1130
1671
 
1131
- function postStatus(stateName, extra) {
1132
- self.postMessage(Object.assign({ type: "status", state: stateName }, extra || {}));
1133
- }
1672
+ // SPDX-License-Identifier: GPL-3.0-only
1134
1673
 
1135
- function postError(requestId, message, isFatalErr, isUnsupported) {
1136
- self.postMessage({ type: "error", requestId: requestId == null ? undefined : requestId, message, fatal: !!isFatalErr, unsupported: !!isUnsupported });
1137
- }
1674
+ // Eddie search worker (classic dedicated worker): the fallback host when the
1675
+ // service worker (eddie-sw.js) is unavailable, and the same protocol either
1676
+ // way. The engine itself is widget/src/lib/search-engine.js; this file only
1677
+ // binds it to a dedicated worker: the WASM glue comes in through
1678
+ // importScripts (no-modules builds: eddie-lite.js first, eddie-dense.js only
1679
+ // when a CPU dense lane is about to run) and transformers.js through a
1680
+ // dynamic import(). widget/build.sh concatenates widget/src/lib/*.js ahead
1681
+ // of this file, so the helpers are available as `EddieLib`.
1682
+ //
1683
+ // Protocol: see widget/README.md ("Worker protocol").
1138
1684
 
1139
- function describe(err) {
1140
- if (err == null) return "unknown error";
1141
- if (typeof err === "string") return err;
1142
- if (err.message) return err.message;
1143
- return String(err);
1144
- }
1685
+ "use strict";
1145
1686
 
1146
- function unsupported(message) {
1147
- const e = new Error(message);
1148
- e.eddieUnsupported = true;
1149
- e.eddieFatal = true;
1150
- return e;
1151
- }
1687
+ const TRANSFORMERS_URL = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0";
1152
1688
 
1153
- function fatal(message) {
1154
- const e = new Error(message);
1155
- e.eddieFatal = true;
1156
- return e;
1157
- }
1689
+ const lib = EddieLib;
1158
1690
 
1159
- function isUnsupported(err) {
1160
- return !!(err && err.eddieUnsupported);
1691
+ const loaded = {}; // variant -> Promise of the wasm-bindgen API object
1692
+
1693
+ function loadVariant(baseUrl, variant) {
1694
+ if (variant !== "lite" && variant !== "dense") throw new Error(`unknown wasm variant ${String(variant)}`);
1695
+ if (!loaded[variant]) {
1696
+ loaded[variant] = (async () => {
1697
+ importScripts(lib.assetUrl(baseUrl, `eddie-${variant}.js`, lib.ASSET_VERSION));
1698
+ // eddie-dense.js is renamed by build.sh so both glues can share this
1699
+ // global scope (a second `let wasm_bindgen` would be a SyntaxError).
1700
+ const api = variant === "dense" ? wasm_bindgen_dense : wasm_bindgen;
1701
+ await api({ module_or_path: lib.assetUrl(baseUrl, `eddie-${variant}.wasm`, lib.ASSET_VERSION) });
1702
+ return api;
1703
+ })();
1704
+ loaded[variant].catch(() => {
1705
+ delete loaded[variant]; // a failed fetch may be retried
1706
+ });
1707
+ }
1708
+ return loaded[variant];
1161
1709
  }
1162
1710
 
1163
- function isFatal(err) {
1164
- if (!err) return false;
1165
- if (err.eddieFatal) return true;
1166
- return typeof WebAssembly === "object" && err instanceof WebAssembly.RuntimeError;
1167
- }
1711
+ const engine = lib.createSearchEngine({
1712
+ post: (message) => self.postMessage(message),
1713
+ loadWasm: (baseUrl, variant) => loadVariant(baseUrl, variant || "lite"),
1714
+ loadTransformers: () => import(TRANSFORMERS_URL),
1715
+ canRunWebGpuLane: true,
1716
+ });
1717
+
1718
+ self.onmessage = function (e) {
1719
+ engine.handle(e.data || {}, (message) => self.postMessage(message));
1720
+ };