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