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.
@@ -2,6 +2,7 @@
2
2
  // Generated by widget/build.sh from widget/src/eddie-widget.js and widget/src/lib/*.js; edit those instead.
3
3
  (function () {
4
4
  "use strict";
5
+ const EDDIE_ASSET_VERSION = "c0fdc503e01a";
5
6
  const EddieLib = {};
6
7
  // SPDX-License-Identifier: GPL-3.0-only
7
8
 
@@ -60,8 +61,10 @@ const EddieLib = {};
60
61
  answerTopK: intAttr(get("data-answer-top-k"), 5),
61
62
  agentMode: oneOf(get("data-agent-mode"), ["off", "auto"], "auto"),
62
63
  agentModel: agentModelRaw === "" ? "auto" : agentModelRaw,
63
- denseRuntime: oneOf(get("data-dense-runtime"), ["auto", "wasm", "webgpu"], "auto"),
64
+ denseRuntime: oneOf(get("data-dense-runtime"), ["auto", "wasm", "webgpu", "off"], "auto"),
64
65
  consentText: (get("data-consent-text") || "").trim(),
66
+ persist: oneOf(get("data-persist"), ["auto", "off"], "auto"),
67
+ warm: oneOf(get("data-warm"), ["auto", "off", "always"], "auto"),
65
68
  };
66
69
  }
67
70
 
@@ -70,6 +73,274 @@ const EddieLib = {};
70
73
 
71
74
  // SPDX-License-Identifier: GPL-3.0-only
72
75
 
76
+ // Visitor preferences: which search model to download, which agent model to
77
+ // run, and how eagerly to load either. Pure functions over the site's
78
+ // `data-*` config, the index's lane list and what the browser can run.
79
+ //
80
+ // The site config is the ceiling: a visitor can always choose less, and can
81
+ // choose among what the site left open, but cannot switch on what the owner
82
+ // turned off. `settingsChoices` is where that rule lives. `effectiveConfig`
83
+ // drops a preference that is no longer on offer -- a re-indexed site, a
84
+ // different browser, a changed `data-*` -- so a stale one degrades to the
85
+ // site default instead of breaking the widget.
86
+
87
+ (function (factory) {
88
+ const api = factory();
89
+ if (typeof module === "object" && module && module.exports) {
90
+ module.exports = api;
91
+ } else if (typeof EddieLib === "object" && EddieLib) {
92
+ Object.assign(EddieLib, api);
93
+ } else {
94
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
95
+ }
96
+ })(function () {
97
+ "use strict";
98
+
99
+ const SETTINGS_KEY = "eddie.settings";
100
+
101
+ // Ladders, least eager first: a visitor may pick any rung up to and
102
+ // including the site's.
103
+ const WARM_RUNGS = ["off", "auto", "always"];
104
+ const PERSIST_RUNGS = ["off", "auto"];
105
+ // The two named agent sizes selectAgentModel understands.
106
+ const AGENT_LEVELS = ["light", "quality"];
107
+
108
+ const FIELDS = ["searchLane", "agentLevel", "warm", "persist"];
109
+
110
+ /** Stored preferences, or {} when there are none, the value is unusable, or storage is unavailable. */
111
+ function readSettings(storage) {
112
+ let raw = null;
113
+ try {
114
+ raw = storage && storage.getItem ? storage.getItem(SETTINGS_KEY) : null;
115
+ } catch (_) {
116
+ return {};
117
+ }
118
+ if (!raw) return {};
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch (_) {
123
+ return {};
124
+ }
125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
126
+ const out = {};
127
+ for (const f of FIELDS) {
128
+ if (typeof parsed[f] === "string" && parsed[f]) out[f] = parsed[f];
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Merge `patch` into the stored preferences and persist. Returns the merged object. */
134
+ function writeSettings(storage, patch) {
135
+ const merged = Object.assign(readSettings(storage), patch || {});
136
+ for (const k of Object.keys(merged)) {
137
+ if (merged[k] == null || merged[k] === "") delete merged[k];
138
+ }
139
+ try {
140
+ if (storage && storage.setItem) storage.setItem(SETTINGS_KEY, JSON.stringify(merged));
141
+ } catch (_) {
142
+ // storage unavailable: the choice applies to this page and is forgotten
143
+ }
144
+ return merged;
145
+ }
146
+
147
+ function clearSettings(storage) {
148
+ try {
149
+ if (storage && storage.removeItem) storage.removeItem(SETTINGS_KEY);
150
+ } catch (_) {
151
+ // nothing to do
152
+ }
153
+ return {};
154
+ }
155
+
156
+ /** Rungs of a ladder up to and including the site's value (which is the ceiling). */
157
+ function rungsUpTo(rungs, siteValue, fallback) {
158
+ const at = rungs.indexOf(siteValue);
159
+ return rungs.slice(0, (at < 0 ? rungs.indexOf(fallback) : at) + 1);
160
+ }
161
+
162
+ /** A lane the site's `data-dense-runtime` allows (`auto` allows both kinds). */
163
+ function laneAllowedByRuntime(lane, denseRuntime) {
164
+ if (denseRuntime === "off") return false;
165
+ if (denseRuntime === "wasm") return lane.kind === "wasm-candle";
166
+ if (denseRuntime === "webgpu") return lane.kind === "webgpu-onnx";
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * What the settings panel may offer.
172
+ *
173
+ * o: { config, lanes, hostSkipped, hasWebGpu }, where `lanes` is null
174
+ * before the engine has read the index.
175
+ *
176
+ * `search` and `agent` are null while `lanes` is: at mount there is neither
177
+ * a lane list nor an adapter probe, and answering "no options" then would
178
+ * throw away a valid stored preference. `warm` and `persist` need only the
179
+ * site config. Every non-null group is a list of `{ value, label, detail }`
180
+ * containing the least-eager option, so the panel renders a radio group
181
+ * without special cases.
182
+ */
183
+ function settingsChoices(o) {
184
+ const opts = o || {};
185
+ const config = opts.config || {};
186
+ const lanesKnown = Array.isArray(opts.lanes);
187
+ const lanes = lanesKnown ? opts.lanes : [];
188
+ const skipped = new Set(Array.isArray(opts.hostSkipped) ? opts.hostSkipped : []);
189
+ const hasWebGpu = !!opts.hasWebGpu;
190
+
191
+ const search = [{ value: "none", label: "Keyword only", detail: "No model download" }];
192
+ for (const lane of lanes) {
193
+ if (!lane || !lane.id || skipped.has(lane.id)) continue;
194
+ if (!laneAllowedByRuntime(lane, config.denseRuntime || "auto")) continue;
195
+ if (lane.kind === "webgpu-onnx" && !hasWebGpu) continue;
196
+ search.push({
197
+ value: lane.id,
198
+ label: lane.model || lane.id,
199
+ detail: lane.kind === "webgpu-onnx" ? "WebGPU" : "CPU",
200
+ origin: lane.origin || null,
201
+ kind: lane.kind || null,
202
+ });
203
+ }
204
+
205
+ // WebLLM needs a WebGPU adapter, so without one the agent is not on offer
206
+ // however the site is configured.
207
+ const agent = [{ value: "off", label: "Off", detail: "No agent download" }];
208
+ const pinned = String(config.agentModel || "auto").trim();
209
+ if (config.agentMode !== "off" && hasWebGpu) {
210
+ if (pinned && pinned !== "auto" && AGENT_LEVELS.indexOf(pinned) < 0) {
211
+ // The site pinned one WebLLM model id: offer that, or nothing.
212
+ agent.push({ value: pinned, label: pinned, detail: "Chosen by this site" });
213
+ } else {
214
+ agent.push({ value: "light", label: "Light", detail: "Smaller model, faster, less accurate" });
215
+ agent.push({ value: "quality", label: "Quality", detail: "Larger model, slower, better answers" });
216
+ }
217
+ }
218
+
219
+ const warm = rungsUpTo(WARM_RUNGS, config.warm, "auto").map((value) => ({
220
+ value,
221
+ label: value === "off" ? "On demand" : value === "auto" ? "When I've searched before" : "Always",
222
+ detail:
223
+ value === "off"
224
+ ? "Load search when I first search"
225
+ : value === "auto"
226
+ ? "Preload on pages I visit after my first search here"
227
+ : "Preload on every page view",
228
+ }));
229
+
230
+ const persist = rungsUpTo(PERSIST_RUNGS, config.persist, "auto").map((value) => ({
231
+ value,
232
+ label: value === "off" ? "Per page" : "Across pages",
233
+ detail:
234
+ value === "off"
235
+ ? "Reload the engine on every page"
236
+ : "Keep the engine in a service worker so other pages start instantly",
237
+ }));
238
+
239
+ return { search: lanesKnown ? search : null, agent: lanesKnown ? agent : null, warm, persist };
240
+ }
241
+
242
+ /** Would the site config allow this search preference, lanes aside? */
243
+ function searchWithinCeiling(config, value) {
244
+ if (!value) return false;
245
+ if (value === "none") return true;
246
+ return ((config || {}).denseRuntime || "auto") !== "off";
247
+ }
248
+
249
+ /** Would the site config allow this agent preference, adapter aside? */
250
+ function agentWithinCeiling(config, value) {
251
+ if (!value) return false;
252
+ if (value === "off") return true;
253
+ return (config || {}).agentMode !== "off";
254
+ }
255
+
256
+ /**
257
+ * What a group shows: the stored preference if it is still on offer, else
258
+ * the site default, else the least-eager option -- never the heaviest, so a
259
+ * panel opened before the engine reports a lane does not imply the biggest
260
+ * model is already running.
261
+ */
262
+ function selected(group, stored, fallback) {
263
+ const has = (v) => group.some((c) => c.value === v);
264
+ if (stored && has(stored)) return stored;
265
+ if (fallback && has(fallback)) return fallback;
266
+ return group.length ? group[0].value : null;
267
+ }
268
+
269
+ /**
270
+ * Which option each group is on. `searchLane` and `agentLevel` have no site
271
+ * default, so the lane the engine actually loaded stands in for the first
272
+ * and the site's `agentModel` for the second.
273
+ */
274
+ function currentSelection(choices, settings, o) {
275
+ const c = choices || {};
276
+ const s = settings || {};
277
+ const opts = o || {};
278
+ const config = opts.config || {};
279
+ // With the site on `auto` the level is whatever selectAgentModel picked
280
+ // for this adapter, so show that rather than guessing "quality".
281
+ const agentFallback = (function () {
282
+ const pinned = String(config.agentModel || "auto").trim();
283
+ if (pinned && pinned !== "auto") return pinned;
284
+ return opts.activeAgent || "quality";
285
+ })();
286
+ return {
287
+ searchLane: selected(c.search || [], s.searchLane, opts.activeLane || null),
288
+ agentLevel: selected(c.agent || [], s.agentLevel, agentFallback),
289
+ warm: selected(c.warm || [], s.warm, config.warm),
290
+ persist: selected(c.persist || [], s.persist, config.persist),
291
+ };
292
+ }
293
+
294
+ /**
295
+ * The site config with the visitor's preferences applied. Preferences that
296
+ * are no longer on offer are ignored, so the result is always something the
297
+ * engine and the transport can act on.
298
+ */
299
+ function effectiveConfig(config, settings, choices) {
300
+ const base = Object.assign({}, config || {});
301
+ const c = choices || {};
302
+ const s = settings || {};
303
+ // A group the widget does not know yet (no lane list at mount) falls back
304
+ // to the site ceiling: the engine validates a pinned lane anyway and
305
+ // reports it if the lane cannot run here.
306
+ const allowed = (group, v, ceiling) =>
307
+ !!v && (group ? group.some((x) => x.value === v) : ceiling(config, v));
308
+
309
+ if (allowed(c.search, s.searchLane, searchWithinCeiling)) {
310
+ if (s.searchLane === "none") {
311
+ base.denseRuntime = "off";
312
+ base.laneId = null;
313
+ } else {
314
+ base.laneId = s.searchLane;
315
+ if (base.denseRuntime === "off") base.denseRuntime = "auto";
316
+ }
317
+ }
318
+ if (allowed(c.agent, s.agentLevel, agentWithinCeiling)) {
319
+ if (s.agentLevel === "off") {
320
+ base.agentMode = "off";
321
+ } else {
322
+ base.agentMode = "auto";
323
+ base.agentModel = s.agentLevel;
324
+ }
325
+ }
326
+ if (allowed(c.warm, s.warm, () => false)) base.warm = s.warm;
327
+ if (allowed(c.persist, s.persist, () => false)) base.persist = s.persist;
328
+ return base;
329
+ }
330
+
331
+ return {
332
+ SETTINGS_KEY,
333
+ readSettings,
334
+ writeSettings,
335
+ clearSettings,
336
+ settingsChoices,
337
+ currentSelection,
338
+ effectiveConfig,
339
+ };
340
+ });
341
+
342
+ // SPDX-License-Identifier: GPL-3.0-only
343
+
73
344
  // URL, version and cache-key helpers shared by the widget and the worker.
74
345
 
75
346
  (function (factory) {
@@ -86,6 +357,17 @@ const EddieLib = {};
86
357
 
87
358
  const HF_HOST = "https://huggingface.co";
88
359
 
360
+ /**
361
+ * `?v=` of the runtime assets (widget, workers, wasm, glue, service
362
+ * workers): a hash of the files that produced them, stamped into every
363
+ * bundle by widget/build.sh. Deliberately not the index's version, which
364
+ * changes on every content rebuild and so re-downloaded the whole engine
365
+ * on every deploy; see "Script tag" in widget/README.md. null outside a
366
+ * bundle (the Node tests), which just means no `?v=`.
367
+ */
368
+ const ASSET_VERSION =
369
+ typeof EDDIE_ASSET_VERSION === "string" && EDDIE_ASSET_VERSION ? EDDIE_ASSET_VERSION : null;
370
+
89
371
  /** Directory of an absolute URL, without query or hash, with a trailing slash. */
90
372
  function baseUrlOf(href) {
91
373
  const u = new URL(href);
@@ -153,6 +435,7 @@ const EddieLib = {};
153
435
 
154
436
  return {
155
437
  HF_HOST,
438
+ ASSET_VERSION,
156
439
  baseUrlOf,
157
440
  versionOf,
158
441
  withVersion,
@@ -167,8 +450,10 @@ const EddieLib = {};
167
450
 
168
451
  // SPDX-License-Identifier: GPL-3.0-only
169
452
 
170
- // Dense-lane selection, download sizes, consent copy and degraded-arm notices.
171
- // Pure functions over the index manifest (see src/manifest.rs).
453
+ // User-facing copy shared by the widget and the search engine: byte
454
+ // formatting, the download consent card and the degraded-arm notices.
455
+ // Split out of lanes.js so the widget bundle carries only this, not the
456
+ // engine-side lane selection.
172
457
 
173
458
  (function (factory) {
174
459
  const api = factory();
@@ -182,128 +467,6 @@ const EddieLib = {};
182
467
  })(function () {
183
468
  "use strict";
184
469
 
185
- const DEFAULT_WASM_FILES = ["config.json", "tokenizer.json", "model.safetensors"];
186
-
187
- // Approximate download sizes (bytes) keyed by lower-cased HuggingFace repo.
188
- const DOWNLOAD_SIZES = {
189
- "sentence-transformers/multi-qa-minilm-l6-cos-v1": 91e6,
190
- "sentence-transformers/all-minilm-l6-v2": 91e6,
191
- "sentence-transformers/all-minilm-l12-v2": 134e6,
192
- "sentence-transformers/paraphrase-multilingual-minilm-l12-v2": 471e6,
193
- "baai/bge-small-en-v1.5": 134e6,
194
- "baai/bge-base-en-v1.5": 438e6,
195
- "onnx-community/qwen3-embedding-0.6b-onnx": 900e6,
196
- "onnx-community/harrier-oss-v1-0.6b-onnx": 400e6,
197
- "xenova/bge-m3": 570e6,
198
- };
199
-
200
- function laneKind(lane) {
201
- return lane && lane.runtime ? lane.runtime.kind : null;
202
- }
203
-
204
- function isWasmLane(lane) {
205
- return laneKind(lane) === "wasm-candle";
206
- }
207
-
208
- function isWebGpuLane(lane) {
209
- return laneKind(lane) === "webgpu-onnx";
210
- }
211
-
212
- /** Repo the runtime downloads from. */
213
- function laneRepo(lane) {
214
- if (isWebGpuLane(lane)) return lane.runtime.repo;
215
- return lane.model;
216
- }
217
-
218
- /**
219
- * HuggingFace revision for the runtime's downloads. `lane.revision` pins
220
- * `lane.model` (the repo the indexer used); a webgpu-onnx lane downloads
221
- * from a different repo (`runtime.repo`) that the manifest does not pin.
222
- */
223
- function laneRevision(lane) {
224
- if (isWebGpuLane(lane)) return "main";
225
- return lane.revision || "main";
226
- }
227
-
228
- /** Files a wasm-candle lane needs (config, tokenizer, weights). */
229
- function laneFiles(lane) {
230
- if (!isWasmLane(lane)) return [];
231
- const files = Array.isArray(lane.runtime.files) ? lane.runtime.files : [];
232
- return files.length ? files.slice() : DEFAULT_WASM_FILES.slice();
233
- }
234
-
235
- function isWeightsFile(file) {
236
- return /\.(safetensors|bin|pt|gguf|onnx)$/i.test(String(file));
237
- }
238
-
239
- /**
240
- * Why the WASM loader (init_dense_wasm) cannot run a wasm-candle lane, or
241
- * null when it can: it accepts BERT-family lanes with config.json,
242
- * tokenizer.json and exactly one unsharded model.safetensors.
243
- */
244
- function wasmLaneProblem(lane) {
245
- if (!isWasmLane(lane)) return "not a wasm-candle lane";
246
- if (lane.family && lane.family !== "bert") return `family ${lane.family} is not supported in WASM`;
247
- const files = laneFiles(lane);
248
- for (const need of ["config.json", "tokenizer.json"]) {
249
- if (!files.includes(need)) return `runtime.files lacks ${need}`;
250
- }
251
- const weights = files.filter(isWeightsFile);
252
- if (weights.length === 1 && weights[0] === "model.safetensors") return null;
253
- if (weights.length === 0) return "runtime.files lists no weights file";
254
- return `WASM needs a single model.safetensors, not ${weights.join(", ")}`;
255
- }
256
-
257
- /**
258
- * Ordered dense-lane candidates for this browser, plus the lanes skipped
259
- * because this runtime cannot load them (`skipped: [{lane, reason}]`).
260
- * `denseRuntime`: "auto" | "wasm" | "webgpu"; `hasWebGpu`: an adapter was obtained.
261
- */
262
- function chooseDenseLanes(manifest, opts) {
263
- const lanes = (manifest && Array.isArray(manifest.dense)) ? manifest.dense : [];
264
- const runtime = (opts && opts.denseRuntime) || "auto";
265
- const hasWebGpu = !!(opts && opts.hasWebGpu);
266
- const webgpu = lanes.filter(isWebGpuLane);
267
- const skipped = [];
268
- const wasm = lanes.filter(isWasmLane).filter((lane) => {
269
- const problem = wasmLaneProblem(lane);
270
- if (problem) skipped.push({ lane, reason: problem });
271
- return !problem;
272
- });
273
- if (lanes.length === 0) {
274
- return { candidates: [], skipped, reason: "index has no dense lane" };
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
- let reason = null;
285
- if (candidates.length === 0) {
286
- if (runtime === "wasm") reason = skipped.length ? "no wasm-candle lane the WASM loader can run" : "index has no wasm-candle lane";
287
- else if (!hasWebGpu && webgpu.length && !wasm.length) reason = "no WebGPU adapter";
288
- else if (runtime === "webgpu" && !hasWebGpu) reason = "no WebGPU adapter";
289
- else if (runtime === "webgpu") reason = "index has no webgpu-onnx lane";
290
- else reason = "no runnable dense lane";
291
- }
292
- return { candidates, skipped, reason };
293
- }
294
-
295
- /** transformers.js dtype for a webgpu-onnx lane on this adapter. */
296
- function pickDtype(runtime, hasF16) {
297
- if (hasF16 && runtime.dtype_f16) return runtime.dtype_f16;
298
- return runtime.dtype || "fp32";
299
- }
300
-
301
- /** Approximate bytes a lane downloads on first use, or null when unknown. */
302
- function laneDownloadBytes(lane) {
303
- const repo = (laneRepo(lane) || "").toLowerCase();
304
- return Object.prototype.hasOwnProperty.call(DOWNLOAD_SIZES, repo) ? DOWNLOAD_SIZES[repo] : null;
305
- }
306
-
307
470
  function formatBytes(n) {
308
471
  if (n == null || !Number.isFinite(n)) return "unknown size";
309
472
  if (n >= 1e9) {
@@ -317,16 +480,21 @@ const EddieLib = {};
317
480
 
318
481
  /**
319
482
  * Consent card copy for a model download. `consentText` (site override) may
320
- * contain `{size}` and `{model}` placeholders.
483
+ * contain `{size}`, `{model}` and `{origin}` placeholders.
484
+ * sizeBytes model download (null when unknown)
485
+ * origin "site" | "huggingface" (where the model is fetched from)
486
+ * sidecarBytes index vectors fetched from the site for this lane (0 when in the core index)
321
487
  */
322
488
  function consentCopy(opts) {
323
489
  const size = formatBytes(opts.sizeBytes);
324
490
  const model = opts.model || "the search model";
491
+ const origin = opts.origin === "site" ? "this site" : "huggingface.co";
325
492
  if (opts.consentText) {
326
- return opts.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, model);
493
+ return opts.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, model).replace(/\{origin\}/g, origin);
327
494
  }
328
- const sized = opts.sizeBytes == null ? "a one-time download (size unknown)" : `a one-time ${size} download`;
329
- 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.`;
495
+ const sized = opts.sizeBytes == null ? `a one-time download (size unknown) from ${origin}` : `a one-time ${size} download from ${origin}`;
496
+ const side = opts.sidecarBytes > 0 ? `, plus ${formatBytes(opts.sidecarBytes)} of index vectors from this site` : "";
497
+ let text = `Semantic search runs ${model} in your browser. That needs ${sized}${side}, kept in your browser's cache for next time.`;
330
498
  if (opts.saveData) {
331
499
  text = "Data saver is on. " + text;
332
500
  }
@@ -337,8 +505,15 @@ const EddieLib = {};
337
505
  * Drop degraded notes that describe the index design rather than a failure
338
506
  * (an index without a dense lane or sparse arm is not "degraded").
339
507
  */
508
+ /**
509
+ * Drop the "degraded" entries that describe how the index or the visitor
510
+ * chose to run, not something going wrong: an index built without a lane,
511
+ * and a visitor who picked keyword-only search in the settings panel.
512
+ */
340
513
  function filterDesignDegraded(degraded) {
341
- return (degraded || []).filter((d) => !/index has no (dense lane|sparse arm)/.test(d));
514
+ return (degraded || []).filter(
515
+ (d) => !/index has no (dense lane|sparse arm)|dense search is turned off/.test(d)
516
+ );
342
517
  }
343
518
 
344
519
  /** Human notice when a semantic arm is missing, or null when nothing to say. */
@@ -356,19 +531,6 @@ const EddieLib = {};
356
531
  }
357
532
 
358
533
  return {
359
- DEFAULT_WASM_FILES,
360
- DOWNLOAD_SIZES,
361
- laneKind,
362
- isWasmLane,
363
- isWebGpuLane,
364
- laneRepo,
365
- laneRevision,
366
- laneFiles,
367
- isWeightsFile,
368
- wasmLaneProblem,
369
- chooseDenseLanes,
370
- pickDtype,
371
- laneDownloadBytes,
372
534
  formatBytes,
373
535
  consentCopy,
374
536
  filterDesignDegraded,
@@ -378,9 +540,10 @@ const EddieLib = {};
378
540
 
379
541
  // SPDX-License-Identifier: GPL-3.0-only
380
542
 
381
- // Agent helpers shared by the widget and the agent worker: model selection,
382
- // prompts, plan parsing, evidence assembly and answer post-processing.
383
- // Pure functions; no WebLLM, no DOM.
543
+ // Agent helpers the widget shows in its UI: model selection, evidence
544
+ // assembly, stream display and the FAQ gate. Pure functions; no WebLLM, no
545
+ // DOM. The prompts and answer post-processing that run beside the model
546
+ // live in agent-llm.js (bundled only into the agent engine's hosts).
384
547
 
385
548
  (function (factory) {
386
549
  const api = factory();
@@ -395,7 +558,6 @@ const EddieLib = {};
395
558
  "use strict";
396
559
 
397
560
  const NOHIT = "The site doesn't cover that.";
398
- const NOHIT_RE = /\bthe site (?:doesn['’]t|does not|didn['’]t|did not) cover (?:that|this|it)\.?/gi;
399
561
 
400
562
  const AGENT_MODEL_SIZES = {
401
563
  "Qwen3.5-0.8B": 0.4e9,
@@ -404,22 +566,6 @@ const EddieLib = {};
404
566
  };
405
567
  const TWO_GIB = 2 * 1024 * 1024 * 1024;
406
568
 
407
- const PLAN_SCHEMA = {
408
- type: "object",
409
- properties: {
410
- queries: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 },
411
- },
412
- required: ["queries"],
413
- };
414
-
415
- function planPrompt(site) {
416
- return `You write search queries for a site search engine. The site is ${site}. Reply with JSON only: {"queries": ["..."]}. Give 1 to 3 different short keyword queries (2 to 5 words each, no punctuation) that a site search engine would match against page text. Each query must be different. Do not answer the question.`;
417
- }
418
-
419
- function answerPrompt(site) {
420
- return `You answer visitor questions about ${site} using only the numbered sources below the question. Answer the question directly in the first sentence. Write 1 to 3 sentences in your own words; never repeat a source's wording. End each sentence with the numbers of the sources it comes from, like [2] or [1][3]. Never cite a number that is not in the list. Do not add calculations or inferences that are not in the sources. If no source answers the question, your entire reply is: ${NOHIT} Never mix that sentence with an answer. Never use outside knowledge.`;
421
- }
422
-
423
569
  /** Strip the WebLLM variant suffix to get the family name shown to visitors. */
424
570
  function baseModelId(id) {
425
571
  return String(id).replace(/-q\d+f(16|32)_\d+-MLC$/i, "");
@@ -432,7 +578,10 @@ const EddieLib = {};
432
578
 
433
579
  /**
434
580
  * Choose the WebLLM model id.
435
- * opts: { mode: "auto"|"quality"|<id>, maxBufferSize, isMobile, hasF16 }
581
+ * opts: { mode: "auto"|"light"|"quality"|<id>, maxBufferSize, isMobile, hasF16 }
582
+ *
583
+ * "light" and "quality" are the two sizes the settings panel offers by
584
+ * name; "auto" picks between them from the adapter's buffer limit.
436
585
  */
437
586
  function selectAgentModel(opts) {
438
587
  const o = opts || {};
@@ -444,6 +593,8 @@ const EddieLib = {};
444
593
  base = big ? "Qwen3.5-2B" : "Qwen3.5-0.8B";
445
594
  } else if (mode === "quality") {
446
595
  base = "Qwen3.5-2B";
596
+ } else if (mode === "light") {
597
+ base = "Qwen3.5-0.8B";
447
598
  } else {
448
599
  return { id: mode, base: baseModelId(mode), sizeBytes: agentModelBytes(mode), explicit: true };
449
600
  }
@@ -459,14 +610,6 @@ const EddieLib = {};
459
610
  return /Mobi|Android|iPhone|iPad|iPod|Windows Phone/i.test(n.userAgent || "");
460
611
  }
461
612
 
462
- /** Remove <think>…</think> blocks; a dangling <think> loses only the tag. */
463
- function stripThink(text) {
464
- if (!text) return "";
465
- let out = String(text).replace(/<think>[\s\S]*?<\/think>/g, "");
466
- out = out.replace(/<think>/g, "").replace(/<\/think>/g, "");
467
- return out.trim();
468
- }
469
-
470
613
  /**
471
614
  * Display text for a partial stream: complete think blocks removed, and
472
615
  * anything after an unclosed <think> hidden until it closes.
@@ -479,50 +622,6 @@ const EddieLib = {};
479
622
  return out.replace(/^\s+/, "");
480
623
  }
481
624
 
482
- function extractJsonObject(text) {
483
- const s = String(text);
484
- const start = s.indexOf("{");
485
- const end = s.lastIndexOf("}");
486
- if (start < 0 || end <= start) return null;
487
- try {
488
- return JSON.parse(s.substring(start, end + 1));
489
- } catch (_) {
490
- return null;
491
- }
492
- }
493
-
494
- function cleanQuery(q) {
495
- return String(q)
496
- .replace(/\/?no_think/gi, "")
497
- .replace(/[\s"'`*]+$/g, "")
498
- .replace(/^[\s"'`*\-\d.)]+/g, "")
499
- .replace(/\s+/g, " ")
500
- .trim();
501
- }
502
-
503
- /** Parse the planner reply into 1..3 distinct queries; falls back to the question. */
504
- function parsePlan(text, question) {
505
- const cleaned = stripThink(text);
506
- const obj = extractJsonObject(cleaned);
507
- const raw = obj && Array.isArray(obj.queries) ? obj.queries : [];
508
- const seen = new Set();
509
- const out = [];
510
- for (const item of raw) {
511
- if (typeof item !== "string") continue;
512
- const q = cleanQuery(item);
513
- if (q.length < 2 || q.length > 80) continue;
514
- const key = q.toLowerCase();
515
- if (seen.has(key)) continue;
516
- seen.add(key);
517
- out.push(q);
518
- if (out.length === 3) break;
519
- }
520
- if (out.length === 0 && question && question.trim()) {
521
- out.push(question.trim());
522
- }
523
- return out;
524
- }
525
-
526
625
  function urlKey(url) {
527
626
  return String(url || "").replace(/#.*$/, "").replace(/\/+$/, "").toLowerCase();
528
627
  }
@@ -551,122 +650,645 @@ const EddieLib = {};
551
650
  return out;
552
651
  }
553
652
 
554
- /** Cut to `max` characters at a word boundary, with an ellipsis. */
555
- function truncateText(text, max) {
556
- const limit = max == null ? 700 : max;
557
- const s = String(text || "").replace(/\s+/g, " ").trim();
558
- if (s.length <= limit) return s;
559
- const cut = s.lastIndexOf(" ", limit - 1);
560
- return s.substring(0, cut > limit * 0.6 ? cut : limit - 1).trim() + "…";
653
+ // FAQ card gate: prefer the WASM's fused `confident` flag (qa_lookup v0.4.1+);
654
+ // older indexes only carry a dense score, so fall back to a plain cutoff.
655
+ function faqPasses(hit, qaMode) {
656
+ if (!hit || typeof hit !== "object") return false;
657
+ if (qaMode === "off") return false;
658
+ if (qaMode === "always") return true;
659
+ if (typeof hit.confident === "boolean") return hit.confident;
660
+ return typeof hit.score === "number" && hit.score >= 0.5;
561
661
  }
562
662
 
563
- /** `[n] title (url)\ntext` blocks joined by blank lines. */
564
- function formatEvidence(items, maxChars) {
565
- return (items || [])
566
- .map((e, i) => `[${i + 1}] ${e.title || e.url} (${e.url})\n${truncateText(e.text || e.snippet || "", maxChars)}`)
567
- .join("\n\n");
663
+ // Turn confident QA hits into agent evidence items ("Q: … A: …") so the
664
+ // answer model sees the FAQ lane, not only chunk text.
665
+ function qaEvidence(hits, max) {
666
+ const limit = max == null ? 2 : max;
667
+ const out = [];
668
+ for (const h of Array.isArray(hits) ? hits : []) {
669
+ if (out.length >= limit) break;
670
+ if (!faqPasses(h, "auto")) continue;
671
+ const q = String(h.question || "").trim();
672
+ const a = String(h.answer || "").trim();
673
+ if (!q || !a) continue;
674
+ out.push({ title: "FAQ: " + q, url: h.source_url || "", text: "Q: " + q + "\nA: " + a, faq: true });
675
+ }
676
+ return out;
568
677
  }
569
678
 
570
- function sourcesPrompt(items, question, maxChars) {
571
- return `Sources:\n\n${formatEvidence(items, maxChars)}\n\nQuestion: ${question}`;
679
+ return {
680
+ faqPasses,
681
+ qaEvidence,
682
+ NOHIT,
683
+ AGENT_MODEL_SIZES,
684
+ baseModelId,
685
+ agentModelBytes,
686
+ selectAgentModel,
687
+ isMobileDevice,
688
+ visibleStreamText,
689
+ mergeEvidence,
690
+ };
691
+ });
692
+
693
+ // SPDX-License-Identifier: GPL-3.0-only
694
+
695
+ // Transports between the widget and its engines.
696
+ //
697
+ // DedicatedWorkerTransport wraps a Worker; ServiceWorkerTransport wraps one
698
+ // MessageChannel to the Eddie service worker (widget/src/eddie-sw.js). Both
699
+ // expose the same surface so the widget does not care which one it holds:
700
+ //
701
+ // call(type, payload, opts?) -> Promise of the reply carrying the requestId
702
+ // on(type, handler) -> unsubscribe fn (unsolicited messages: status,
703
+ // ready, progress, token, done, aborted, error,
704
+ // reset, crash)
705
+ // postRaw(message) fire-and-forget (abort, ask)
706
+ // nextId() request id for postRaw flows
707
+ // terminate()
708
+ //
709
+ // The pure decision helpers at the bottom (transport kind, keepalive, state
710
+ // reuse) are what the node tests cover.
711
+
712
+ (function (factory) {
713
+ const api = factory();
714
+ if (typeof module === "object" && module && module.exports) {
715
+ module.exports = api;
716
+ } else if (typeof EddieLib === "object" && EddieLib) {
717
+ Object.assign(EddieLib, api);
718
+ } else {
719
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
572
720
  }
721
+ })(function () {
722
+ "use strict";
573
723
 
574
- /**
575
- * Post-process a raw model answer against the evidence list.
576
- * Returns { answer, citations: [{n, url, title}], nohit }.
577
- */
578
- function postProcessAnswer(raw, evidence) {
579
- const ev = Array.isArray(evidence) ? evidence : [];
580
- let text = stripThink(raw);
581
- // **[1]** -> [1]
582
- text = text.replace(/\*\*\s*((?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+)\*\*/g, "$1");
583
- // Lines that are only citation markers belong to the previous line.
584
- const lines = text.split(/\r?\n/);
585
- const merged = [];
586
- for (const line of lines) {
587
- const t = line.trim();
588
- if (t && /^(?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+$/.test(t)) {
589
- let j = merged.length - 1;
590
- while (j >= 0 && merged[j].trim() === "") j--;
591
- if (j >= 0) {
592
- // Append only markers the previous line does not already carry.
593
- const prev = merged[j];
594
- const fresh = (t.match(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g) || []).filter((m) => !prev.includes(m.replace(/\s+/g, "")));
595
- merged.length = j + 1;
596
- if (fresh.length) merged[j] = prev.replace(/\s+$/, "") + " " + fresh.join("");
597
- continue;
724
+ const HELLO_TIMEOUT_MS = 3000;
725
+ // Liveness checks must tolerate a busy worker: model loading blocks its
726
+ // thread for seconds at a time (ONNX session creation, safetensors parsing).
727
+ const PING_TIMEOUT_MS = 10000;
728
+ const IDLE_PING_TIMEOUT_MS = 2000;
729
+ const RECONNECT_HELLO_TIMEOUT_MS = 10000;
730
+ const KEEPALIVE_MS = 15000;
731
+ const REGISTER_TIMEOUT_MS = 20000;
732
+
733
+ class Emitter {
734
+ constructor() {
735
+ this.handlers = new Map();
736
+ }
737
+ on(type, handler) {
738
+ if (!this.handlers.has(type)) this.handlers.set(type, new Set());
739
+ this.handlers.get(type).add(handler);
740
+ return () => this.off(type, handler);
741
+ }
742
+ off(type, handler) {
743
+ const set = this.handlers.get(type);
744
+ if (set) set.delete(handler);
745
+ }
746
+ emit(type, msg) {
747
+ const set = this.handlers.get(type);
748
+ if (!set) return false;
749
+ for (const h of Array.from(set)) {
750
+ try {
751
+ h(msg);
752
+ } catch (err) {
753
+ console.warn("eddie transport handler failed", err);
598
754
  }
599
755
  }
600
- merged.push(line);
756
+ return set.size > 0;
601
757
  }
602
- text = merged.join("\n");
758
+ }
759
+
760
+ function replyError(msg) {
761
+ const err = new Error(msg.message || "request failed");
762
+ err.fatal = !!msg.fatal;
763
+ err.unsupported = !!msg.unsupported;
764
+ return err;
765
+ }
603
766
 
604
- // Drop the fallback sentence when anything else remains.
605
- const withoutFallback = text.replace(NOHIT_RE, "").replace(/[ \t]+\n/g, "\n").trim();
606
- const residue = withoutFallback
607
- .replace(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g, "")
608
- .replace(/^\s*(?:yes|no)\b/i, "")
609
- .replace(/[\s.,;:!?'"*-]+/g, "");
610
- const onlyFallback = residue === "";
611
- if (onlyFallback) {
612
- return { answer: NOHIT, citations: [], nohit: true };
767
+ /** Request/reply bookkeeping shared by both transports. */
768
+ class BaseTransport extends Emitter {
769
+ constructor(env) {
770
+ super();
771
+ this.env = env || {};
772
+ this.pending = new Map(); // requestId -> { resolve, reject, timer }
773
+ this.seq = 0;
774
+ this.closed = false;
775
+ }
776
+ /** Ids for the transport's own requests (hello, ping, state); the widget numbers its own. */
777
+ nextId() {
778
+ return "t" + ++this.seq;
779
+ }
780
+ call(type, payload, opts) {
781
+ const o = opts || {};
782
+ return new Promise((resolve, reject) => {
783
+ if (this.closed) {
784
+ reject(new Error("transport closed"));
785
+ return;
786
+ }
787
+ const requestId = o.requestId != null ? o.requestId : this.nextId();
788
+ const entry = { resolve, reject, timer: null };
789
+ if (o.timeoutMs) {
790
+ const st = this.env.setTimeout || setTimeout;
791
+ entry.timer = st(() => {
792
+ if (this.pending.get(requestId) !== entry) return;
793
+ this.pending.delete(requestId);
794
+ const err = new Error(`${type}: no reply within ${o.timeoutMs} ms`);
795
+ err.timeout = true;
796
+ reject(err);
797
+ }, o.timeoutMs);
798
+ }
799
+ this.pending.set(requestId, entry);
800
+ try {
801
+ this.postRaw(Object.assign({ type, requestId }, payload || {}));
802
+ } catch (err) {
803
+ this.pending.delete(requestId);
804
+ reject(err);
805
+ }
806
+ });
807
+ }
808
+ failAllPending(err) {
809
+ const entries = Array.from(this.pending.values());
810
+ this.pending.clear();
811
+ for (const p of entries) {
812
+ if (p.timer) (this.env.clearTimeout || clearTimeout)(p.timer);
813
+ p.reject(err);
814
+ }
815
+ }
816
+ handleMessage(msg) {
817
+ const m = msg || {};
818
+ if (m.requestId != null && this.pending.has(m.requestId)) {
819
+ const p = this.pending.get(m.requestId);
820
+ // Streaming replies (token) share the requestId with the final
821
+ // `done`; only terminal messages settle the promise.
822
+ if (m.type === "token" || m.type === "progress") {
823
+ this.emit(m.type, m);
824
+ return;
825
+ }
826
+ this.pending.delete(m.requestId);
827
+ if (p.timer) (this.env.clearTimeout || clearTimeout)(p.timer);
828
+ if (m.type === "error") {
829
+ p.reject(replyError(m));
830
+ if (m.fatal) this.emit("error", m);
831
+ } else {
832
+ p.resolve(m);
833
+ }
834
+ return;
835
+ }
836
+ this.emit(m.type, m);
837
+ }
838
+ }
839
+
840
+ /** A dedicated Worker (classic or module). */
841
+ class DedicatedWorkerTransport extends BaseTransport {
842
+ constructor(url, opts) {
843
+ super(opts && opts.env);
844
+ const o = opts || {};
845
+ const WorkerCtor = o.Worker || globalThis.Worker;
846
+ this.kind = "worker";
847
+ this.worker = o.type ? new WorkerCtor(url, { type: o.type }) : new WorkerCtor(url);
848
+ this.worker.onmessage = (e) => this.handleMessage(e.data || {});
849
+ this.worker.onerror = (e) => {
850
+ const message = e && e.message ? e.message : "worker failed to load";
851
+ const err = new Error(message);
852
+ this.failAllPending(err);
853
+ this.emit("crash", { type: "crash", message });
854
+ };
613
855
  }
614
- text = withoutFallback;
856
+ get persistent() {
857
+ return false;
858
+ }
859
+ postRaw(msg) {
860
+ this.worker.postMessage(msg);
861
+ }
862
+ ping() {
863
+ return Promise.resolve({ type: "pong" });
864
+ }
865
+ setKeepalive() {
866
+ // a dedicated worker lives as long as the page
867
+ }
868
+ terminate() {
869
+ this.closed = true;
870
+ this.failAllPending(new Error("worker terminated"));
871
+ this.worker.terminate();
872
+ }
873
+ }
615
874
 
616
- // Map [n] citations to evidence; drop out-of-range ones.
617
- const cited = [];
618
- const seen = new Set();
619
- text = text.replace(/\[\s*(\d+(?:\s*,\s*\d+)*)\s*\]/g, (_, body) => {
620
- const nums = body.split(",").map((x) => Number(x.trim()));
621
- let out = "";
622
- for (const n of nums) {
623
- if (n >= 1 && n <= ev.length) {
624
- out += `[${n}]`;
625
- if (!seen.has(n)) {
626
- seen.add(n);
627
- cited.push(n);
875
+ /**
876
+ * One MessageChannel to the Eddie service worker. `connect()` transfers a
877
+ * port with a `connect` message and waits for `hello`; the keepalive pings
878
+ * catch a service worker Chrome stopped while idle (ports die with it) and
879
+ * reconnect, emitting `reset` so the widget re-runs its init flow.
880
+ */
881
+ class ServiceWorkerTransport extends BaseTransport {
882
+ constructor(registration, opts) {
883
+ super(opts && opts.env);
884
+ const o = opts || {};
885
+ this.kind = "sw";
886
+ this.registration = registration;
887
+ this.channelKind = o.kind || "search";
888
+ this.version = o.version || null;
889
+ this.helloTimeoutMs = o.helloTimeoutMs || HELLO_TIMEOUT_MS;
890
+ this.pingTimeoutMs = o.pingTimeoutMs || PING_TIMEOUT_MS;
891
+ this.keepaliveMs = o.keepaliveMs || KEEPALIVE_MS;
892
+ this.MessageChannel = o.MessageChannel || globalThis.MessageChannel;
893
+ this.port = null;
894
+ this.hello = null;
895
+ this.keepaliveTimer = null;
896
+ this.reconnecting = null;
897
+ }
898
+ get persistent() {
899
+ return true;
900
+ }
901
+ activeWorker() {
902
+ const r = this.registration;
903
+ return r.active || r.waiting || r.installing || null;
904
+ }
905
+ async connect() {
906
+ const sw = this.activeWorker();
907
+ if (!sw) throw new Error("service worker not active");
908
+ if (this.port) {
909
+ try {
910
+ this.port.close();
911
+ } catch (_) {
912
+ // ignore
913
+ }
914
+ }
915
+ const ch = new this.MessageChannel();
916
+ ch.port1.onmessage = (e) => this.handleMessage(e.data || {});
917
+ this.port = ch.port1;
918
+ sw.postMessage({ type: "connect", kind: this.channelKind, version: this.version }, [ch.port2]);
919
+ this.hello = await this.call("hello", {}, { timeoutMs: this.helloTimeoutMs });
920
+ return this.hello;
921
+ }
922
+ postRaw(msg) {
923
+ if (!this.port) throw new Error("service worker transport not connected");
924
+ this.port.postMessage(msg);
925
+ }
926
+ ping(timeoutMs) {
927
+ return this.call("ping", {}, { timeoutMs: timeoutMs || this.pingTimeoutMs });
928
+ }
929
+ state() {
930
+ return this.call("state", {}, { timeoutMs: this.pingTimeoutMs });
931
+ }
932
+ /** Re-establish the channel after the service worker was stopped. */
933
+ reconnect() {
934
+ if (this.reconnecting) return this.reconnecting;
935
+ this.reconnecting = (async () => {
936
+ this.failAllPending(new Error("service worker restarted"));
937
+ const saved = this.helloTimeoutMs;
938
+ this.helloTimeoutMs = Math.max(saved, RECONNECT_HELLO_TIMEOUT_MS);
939
+ let hello;
940
+ try {
941
+ hello = await this.connect();
942
+ } finally {
943
+ this.helloTimeoutMs = saved;
944
+ }
945
+ this.emit("reset", { type: "reset", hello });
946
+ return hello;
947
+ })().finally(() => {
948
+ this.reconnecting = null;
949
+ });
950
+ return this.reconnecting;
951
+ }
952
+ /**
953
+ * Ping; on silence reconnect. Resolves true when the channel is usable.
954
+ * `timeoutMs` may be short when the worker is known to be idle (a live
955
+ * idle worker answers within milliseconds; a stopped one never does),
956
+ * and must be generous while it may be loading a model.
957
+ */
958
+ async ensureAlive(timeoutMs) {
959
+ try {
960
+ await this.ping(timeoutMs);
961
+ return true;
962
+ } catch (err) {
963
+ if (this.closed) return false;
964
+ console.debug(`eddie: ${this.channelKind} channel silent (${err && err.message}); reconnecting`);
965
+ try {
966
+ await this.reconnect();
967
+ console.debug(`eddie: ${this.channelKind} channel reconnected`);
968
+ return true;
969
+ } catch (err2) {
970
+ console.warn("eddie: service worker reconnect failed", err2);
971
+ this.emit("crash", { type: "crash", message: err2 && err2.message ? err2.message : String(err2) });
972
+ return false;
973
+ }
974
+ }
975
+ }
976
+ /**
977
+ * Keepalive pings are fire-and-forget: their only job is to keep Chrome
978
+ * from stopping the worker as idle. Liveness is checked explicitly
979
+ * (ensureAlive) at the points where a dead worker would otherwise hang
980
+ * the widget, so a worker that is merely busy is never mistaken for a
981
+ * stopped one.
982
+ */
983
+ setKeepalive(wanted) {
984
+ const si = this.env.setInterval || setInterval;
985
+ const ci = this.env.clearInterval || clearInterval;
986
+ if (wanted && !this.keepaliveTimer) {
987
+ this.keepaliveTimer = si(() => {
988
+ try {
989
+ this.postRaw({ type: "ping", requestId: this.nextId() });
990
+ } catch (_) {
991
+ // not connected; ensureAlive will deal with it
628
992
  }
993
+ }, this.keepaliveMs);
994
+ } else if (!wanted && this.keepaliveTimer) {
995
+ ci(this.keepaliveTimer);
996
+ this.keepaliveTimer = null;
997
+ }
998
+ }
999
+ terminate() {
1000
+ this.closed = true;
1001
+ this.setKeepalive(false);
1002
+ this.failAllPending(new Error("transport closed"));
1003
+ if (this.port) {
1004
+ try {
1005
+ this.port.close();
1006
+ } catch (_) {
1007
+ // ignore
629
1008
  }
1009
+ this.port = null;
630
1010
  }
631
- return out;
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Register the Eddie service worker and wait until a worker is active.
1016
+ * Never use `navigator.serviceWorker.ready` here: it only resolves for the
1017
+ * registration controlling the *page*, and pages live outside the asset
1018
+ * directory that scopes this worker.
1019
+ */
1020
+ function registerServiceWorker(opts) {
1021
+ const o = opts || {};
1022
+ const container = o.container;
1023
+ const st = o.setTimeout || setTimeout;
1024
+ const ct = o.clearTimeout || clearTimeout;
1025
+ return new Promise((resolve, reject) => {
1026
+ let done = false;
1027
+ const timer = st(() => {
1028
+ if (done) return;
1029
+ done = true;
1030
+ reject(new Error("service worker registration timed out"));
1031
+ }, o.timeoutMs || REGISTER_TIMEOUT_MS);
1032
+ const finish = (fn) => {
1033
+ if (done) return;
1034
+ done = true;
1035
+ ct(timer);
1036
+ fn();
1037
+ };
1038
+ Promise.resolve()
1039
+ .then(() => container.register(o.url, { type: "module", scope: o.scope, updateViaCache: "none" }))
1040
+ .then((registration) => {
1041
+ // A container that resolves register() with nothing at all: some
1042
+ // privacy modes and test harnesses do this rather than rejecting.
1043
+ // Treated as a plain registration failure, so the caller falls
1044
+ // back to page workers with a message that says so.
1045
+ if (!registration) {
1046
+ finish(() => reject(new Error("service worker registration was refused")));
1047
+ return;
1048
+ }
1049
+ if (registration.active) {
1050
+ finish(() => resolve(registration));
1051
+ return;
1052
+ }
1053
+ const sw = registration.installing || registration.waiting;
1054
+ if (!sw) {
1055
+ finish(() => reject(new Error("service worker registration has no worker")));
1056
+ return;
1057
+ }
1058
+ const onChange = () => {
1059
+ if (sw.state === "activated") {
1060
+ sw.removeEventListener("statechange", onChange);
1061
+ finish(() => resolve(registration));
1062
+ } else if (sw.state === "redundant") {
1063
+ sw.removeEventListener("statechange", onChange);
1064
+ finish(() => reject(new Error("service worker became redundant during install")));
1065
+ }
1066
+ };
1067
+ sw.addEventListener("statechange", onChange);
1068
+ onChange();
1069
+ })
1070
+ .catch((err) => finish(() => reject(err)));
632
1071
  });
633
- // Collapse duplicate adjacent markers ("[1][1]") and tidy whitespace.
634
- text = text.replace(/(\[\d+\])(?:\s*\1)+/g, "$1");
635
- text = text.replace(/[ \t]{2,}/g, " ").replace(/ +([.,;:!?])/g, "$1").replace(/\n{3,}/g, "\n\n").trim();
636
- const citations = cited
637
- .sort((a, b) => a - b)
638
- .map((n) => ({ n, url: ev[n - 1].url, title: ev[n - 1].title || ev[n - 1].url }));
639
- return { answer: text, citations, nohit: text === "" };
1072
+ }
1073
+
1074
+ // -- pure decisions -----------------------------------------------------
1075
+
1076
+ /**
1077
+ * "sw" when persistence is on and the browser can host it, else "worker".
1078
+ * Service workers need a secure context (localhost counts).
1079
+ */
1080
+ function chooseTransportKind(opts) {
1081
+ const o = opts || {};
1082
+ if (o.persist === "off") return "worker";
1083
+ if (!o.hasServiceWorker) return "worker";
1084
+ if (o.secureContext === false) return "worker";
1085
+ return "sw";
1086
+ }
1087
+
1088
+ /** Keep the service worker alive while the visitor is using it. */
1089
+ function keepaliveWanted(opts) {
1090
+ const o = opts || {};
1091
+ return !!(o.open || o.streaming || o.pending);
1092
+ }
1093
+
1094
+ /** The service worker already holds the model this page would load. */
1095
+ function canReuseAgent(state, modelId) {
1096
+ return !!(state && state.loaded && modelId && state.model === modelId);
1097
+ }
1098
+
1099
+ /** The service worker already holds a ready engine for this exact index URL. */
1100
+ function canReuseSearch(state, indexUrl) {
1101
+ return !!(state && state.phase === "ready" && state.indexLoaded && indexUrl && state.indexUrl === indexUrl);
1102
+ }
1103
+
1104
+ /**
1105
+ * Whether search should stay in a page-side worker even though a service
1106
+ * worker is available: the service worker cannot run webgpu-onnx lanes
1107
+ * (`swOnnx` false, no WebGPU in its scope) while this page has an adapter
1108
+ * and the site allows the WebGPU runtime. Persistence is not worth a
1109
+ * quieter dense lane. Only the gpu tier can run those lanes at all; the
1110
+ * lite and dense tiers never hold a webgpu lane, so the question does not
1111
+ * arise for them.
1112
+ */
1113
+ function searchStaysOnPage(opts) {
1114
+ const o = opts || {};
1115
+ if (o.tier && o.tier !== "gpu") return false;
1116
+ if (o.swOnnx) return false;
1117
+ if (!o.pageHasGpu) return false;
1118
+ return o.denseRuntime !== "wasm";
1119
+ }
1120
+
1121
+ // -- service worker tiers ------------------------------------------------
1122
+ //
1123
+ // Four builds of widget/src/eddie-sw.js (see widget/build.sh), each in
1124
+ // its own scope under the asset directory, so a page installs only the
1125
+ // imports its visitor has opted into:
1126
+ // lite eddie-sw-lite.js keyword + sparse search (lite wasm)
1127
+ // dense eddie-sw-dense.js + the CPU dense lane (dense wasm)
1128
+ // gpu eddie-sw-gpu.js + transformers.js (WebGPU search lane)
1129
+ // agent eddie-sw-agent.js WebLLM + the agent engine, registered at
1130
+ // agent consent; never a search host
1131
+
1132
+ // The search tiers. The agent tier is separate: no index lane maps to it
1133
+ // and it is never remembered as a search tier.
1134
+ const SW_TIERS = ["lite", "dense", "gpu"];
1135
+
1136
+ function swScriptName(tier) {
1137
+ return `eddie-sw-${tier}.js`;
1138
+ }
1139
+
1140
+ /** Registration scope of a tier: `<asset dir>sw/<tier>/` (a key, never navigated to). */
1141
+ function swScope(baseUrl, tier) {
1142
+ return String(baseUrl).replace(/\/?$/, "/") + "sw/" + tier + "/";
1143
+ }
1144
+
1145
+ /** The tier that can host a dense lane of `kind` ("wasm-candle" | "webgpu-onnx" | null). */
1146
+ function tierForLane(kind) {
1147
+ if (kind === "webgpu-onnx") return "gpu";
1148
+ if (kind === "wasm-candle") return "dense";
1149
+ return "lite";
1150
+ }
1151
+
1152
+ /**
1153
+ * Which tier the search engine should live in right now: the tier of the
1154
+ * lane about to be loaded (`laneKind`, known once the engine asked for
1155
+ * consent or the visitor accepted), else the tier remembered from an
1156
+ * earlier consent on this browser (`rememberedTier`), else lite.
1157
+ */
1158
+ function searchTierFor(opts) {
1159
+ const o = opts || {};
1160
+ if (o.laneKind) return tierForLane(o.laneKind);
1161
+ if (SW_TIERS.includes(o.rememberedTier)) return o.rememberedTier;
1162
+ return "lite";
1163
+ }
1164
+
1165
+ /**
1166
+ * Eddie 0.4.2 registered one service worker with scope = the asset
1167
+ * directory. Its script (eddie-sw.js) no longer ships; unregister it so
1168
+ * the browser stops trying to update it. Any other registration matching
1169
+ * that URL (the site's own worker at "/") is left alone.
1170
+ */
1171
+ async function unregisterLegacyServiceWorker(container, baseUrl) {
1172
+ if (!container || typeof container.getRegistration !== "function") return false;
1173
+ let reg;
1174
+ try {
1175
+ reg = await container.getRegistration(baseUrl);
1176
+ } catch (_) {
1177
+ return false;
1178
+ }
1179
+ if (!reg || reg.scope !== baseUrl) return false;
1180
+ try {
1181
+ await reg.unregister();
1182
+ return true;
1183
+ } catch (_) {
1184
+ return false;
1185
+ }
640
1186
  }
641
1187
 
642
1188
  return {
643
- NOHIT,
644
- AGENT_MODEL_SIZES,
645
- PLAN_SCHEMA,
646
- planPrompt,
647
- answerPrompt,
648
- sourcesPrompt,
649
- baseModelId,
650
- agentModelBytes,
651
- selectAgentModel,
652
- isMobileDevice,
653
- stripThink,
654
- visibleStreamText,
655
- parsePlan,
656
- mergeEvidence,
657
- truncateText,
658
- formatEvidence,
659
- postProcessAnswer,
1189
+ SW_TIERS,
1190
+ swScriptName,
1191
+ swScope,
1192
+ tierForLane,
1193
+ searchTierFor,
1194
+ unregisterLegacyServiceWorker,
1195
+ HELLO_TIMEOUT_MS,
1196
+ PING_TIMEOUT_MS,
1197
+ IDLE_PING_TIMEOUT_MS,
1198
+ KEEPALIVE_MS,
1199
+ Emitter,
1200
+ BaseTransport,
1201
+ DedicatedWorkerTransport,
1202
+ ServiceWorkerTransport,
1203
+ registerServiceWorker,
1204
+ chooseTransportKind,
1205
+ keepaliveWanted,
1206
+ canReuseAgent,
1207
+ canReuseSearch,
1208
+ searchStaysOnPage,
660
1209
  };
661
1210
  });
662
1211
 
663
1212
  // SPDX-License-Identifier: GPL-3.0-only
664
1213
 
1214
+ // Warm-at-load decision (`data-warm`): whether the widget should initialise
1215
+ // the search engine before the visitor opens the modal. Pure; the widget
1216
+ // supplies what it knows and acts on the returned action.
1217
+
1218
+ (function (factory) {
1219
+ const api = factory();
1220
+ if (typeof module === "object" && module && module.exports) {
1221
+ module.exports = api;
1222
+ } else if (typeof EddieLib === "object" && EddieLib) {
1223
+ Object.assign(EddieLib, api);
1224
+ } else {
1225
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
1226
+ }
1227
+ })(function () {
1228
+ "use strict";
1229
+
1230
+ const SEARCH_CONSENT_KEY = "eddie.search.consent";
1231
+ // The service worker tier that consent needs ("dense" | "gpu"), so a
1232
+ // returning visitor's warm-up registers the right worker at once.
1233
+ const SEARCH_TIER_KEY = "eddie.search.tier";
1234
+ // Set on the first modal open: from then on the visitor is "returning".
1235
+ const SEARCH_USED_KEY = "eddie.search.used";
1236
+
1237
+ /**
1238
+ * opts:
1239
+ * mode "auto" | "off" | "always"
1240
+ * saveData navigator.connection.saveData
1241
+ * returning the visitor opened the search or accepted a model on this
1242
+ * browser before (localStorage); a first-time visitor never
1243
+ * warms in "auto", so a plain page view registers nothing
1244
+ * engineReady a persistent engine already reports `ready` for this index
1245
+ * checked a cache_check result is in (lane/cached below are valid)
1246
+ * lane the dense lane the engine would load ({id} or null)
1247
+ * cached that lane's files are in IndexedDB
1248
+ * consentedLane lane id from localStorage (the visitor accepted it before)
1249
+ *
1250
+ * Returns { action: "none" | "adopt" | "check" | "init", consent, reason }.
1251
+ * adopt: take over the ready engine, no init needed
1252
+ * check: run cache_check (site assets only), then call again with the result
1253
+ * init : send init; `consent` says whether to pass consent=true
1254
+ */
1255
+ function decideWarm(opts) {
1256
+ const o = opts || {};
1257
+ const mode = o.mode || "auto";
1258
+ if (mode === "off") return { action: "none", consent: false, reason: "warm is off" };
1259
+ if (o.engineReady) return { action: "adopt", consent: false, reason: "engine already ready" };
1260
+ if (!o.checked) {
1261
+ if (mode === "auto" && o.saveData) return { action: "none", consent: false, reason: "data saver" };
1262
+ if (mode === "auto" && !o.returning) return { action: "none", consent: false, reason: "first visit" };
1263
+ return { action: "check", consent: false, reason: "cache state unknown" };
1264
+ }
1265
+ if (!o.lane) return { action: "init", consent: false, reason: "no dense lane to download" };
1266
+ if (o.cached) {
1267
+ if (mode === "always") return { action: "init", consent: false, reason: "lane cached (always)" };
1268
+ if (o.consentedLane && o.consentedLane === o.lane.id) return { action: "init", consent: false, reason: "lane cached and consented before" };
1269
+ return { action: "none", consent: false, reason: "lane cached but never consented on this browser" };
1270
+ }
1271
+ if (mode === "always") {
1272
+ if (o.saveData) return { action: "none", consent: false, reason: "data saver" };
1273
+ return { action: "init", consent: true, reason: "always warms uncached lanes" };
1274
+ }
1275
+ return { action: "none", consent: false, reason: "lane not cached" };
1276
+ }
1277
+
1278
+ return { SEARCH_CONSENT_KEY, SEARCH_TIER_KEY, SEARCH_USED_KEY, decideWarm };
1279
+ });
1280
+
1281
+ // SPDX-License-Identifier: GPL-3.0-only
1282
+
665
1283
  // Eddie search widget
666
1284
  //
667
1285
  // Self-contained vanilla JS widget in a closed Shadow DOM. Talks to the
668
- // search worker (eddie-worker.js) for retrieval and, on demand, to the agent
669
- // worker (eddie-agent-worker.js) for a streamed, cited answer.
1286
+ // search engine for retrieval and, on demand, to the agent for a streamed,
1287
+ // cited answer. Both engines live either in the Eddie service worker
1288
+ // (eddie-sw.js, persistent across navigations; see "Persistent engines" in
1289
+ // widget/README.md) or, as the fallback, in page-side workers
1290
+ // (eddie-worker.js, eddie-agent-worker.js). The transports in lib/transport.js
1291
+ // hide the difference.
670
1292
  //
671
1293
  // widget/build.sh concatenates widget/src/lib/*.js ahead of this file inside
672
1294
  // one IIFE, so the pure helpers are available as `EddieLib` without leaking a
@@ -677,9 +1299,23 @@ const EddieLib = {};
677
1299
  (function () {
678
1300
  const scriptEl = document.currentScript;
679
1301
  if (!scriptEl) return;
1302
+ // Loaded twice (a page that carries eddie-boot.js and eddie-widget.js, or
1303
+ // a second injection): the first mount wins.
1304
+ if (document.getElementById("eddie-host")) return;
680
1305
  const lib = EddieLib;
681
1306
 
682
- const config = lib.parseWidgetConfig((name) => scriptEl.getAttribute(name));
1307
+ // The site's data-* config is the ceiling; the visitor's stored
1308
+ // preferences (gear menu) are applied on top of it. The lane list and the
1309
+ // WebGPU probe are not available yet, so this first pass only enforces the
1310
+ // ceiling -- the panel corrects any stale preference once the engine
1311
+ // reports what the index actually offers.
1312
+ const siteConfig = lib.parseWidgetConfig((name) => scriptEl.getAttribute(name));
1313
+ let settings = lib.readSettings(localStore());
1314
+ let config = lib.effectiveConfig(
1315
+ siteConfig,
1316
+ settings,
1317
+ lib.settingsChoices({ config: siteConfig, lanes: null })
1318
+ );
683
1319
  const scriptHref = new URL(scriptEl.src, location.href).href;
684
1320
  const baseUrl = lib.baseUrlOf(scriptHref);
685
1321
  const indexUrl = config.indexUrl
@@ -689,6 +1325,8 @@ const EddieLib = {};
689
1325
  const siteName = config.qaSubject || location.hostname;
690
1326
  const reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
691
1327
  const saveData = !!(navigator.connection && navigator.connection.saveData);
1328
+ // Read once, before any open on this page marks the visitor as returning.
1329
+ const returningAtLoad = !!(readSearchConsent() || searchUsedBefore());
692
1330
 
693
1331
  const HEART_SPRITES = [
694
1332
  [".11111.", "1222221", "1222221", ".12221."], // solid
@@ -699,27 +1337,59 @@ const EddieLib = {};
699
1337
  const HEART_PALETTE = { 1: "#f2c94c", 2: "#e0b63f", 3: "#f8dda1", 4: "#b78e28" };
700
1338
  const HIDDEN_SECTIONS = /^(summary|summary lane|semantic segment)$/i;
701
1339
  const AGENT_CONSENT_KEY = "eddie.agent.consent";
1340
+ const SEARCH_CONSENT_KEY = lib.SEARCH_CONSENT_KEY;
1341
+ const SEARCH_TIER_KEY = lib.SEARCH_TIER_KEY;
1342
+ const SEARCH_USED_KEY = lib.SEARCH_USED_KEY;
1343
+ // How long a modal open waits for the transport decision (service worker
1344
+ // registration + hello) before starting a page-side worker instead.
1345
+ const TRANSPORT_WAIT_MS = 3000;
1346
+ // Longest a search-engine request may stay unanswered over the service
1347
+ // worker before the widget checks whether the worker is still alive.
1348
+ const SW_CALL_TIMEOUT_MS = 60000;
1349
+
1350
+ /** `localStorage`, or null where reading it throws (some privacy modes). */
1351
+ function localStore() {
1352
+ try {
1353
+ return window.localStorage;
1354
+ } catch (_) {
1355
+ return null;
1356
+ }
1357
+ }
702
1358
 
703
1359
  // -- State --
704
- let worker = null;
1360
+ let laneInfo = null; // { lanes, hostSkipped } once the engine has read the index
1361
+ let settingsOpen = false;
1362
+ let search = null; // search transport (lib/transport.js), null until needed
705
1363
  let workerState = "idle"; // idle | loading | index_ready | awaiting_consent | ready | error | dead
706
1364
  let searchable = false;
1365
+ let initSent = false; // an init went to the current search transport
707
1366
  let requestSeq = 0;
708
- const pending = new Map(); // requestId -> { resolve, reject }
709
1367
  let activeSearchId = 0;
710
1368
  let isOpen = false;
711
1369
  let selectedIndex = -1;
712
1370
  let currentResults = [];
713
1371
  let lastHeartIndex = -1;
714
1372
  let consentDeclined = false;
1373
+ let pendingConsentLane = null; // lane id of the consent card being shown
715
1374
  let lastDegradedNotice = null;
716
1375
  let lastAnnouncedQuarter = -1;
717
1376
  let manifestInfo = null;
718
1377
  let searchTimer = null;
719
1378
  let lastSearchedQuery = "";
1379
+ let initWaiters = []; // resolve() once the index is loaded (index_ready or ready)
1380
+ const transportPlans = Object.create(null); // tier -> Promise<{kind, registration?, hello?, sw?}>
1381
+ let searchTier = null; // "lite" | "dense" | "gpu": the tier the search transport was made for
1382
+ let pendingConsentKind = null; // runtime kind of the lane the consent card is for
1383
+ let searchCreating = null; // Promise while ensureSearchTransport runs
1384
+ let switching = null; // Promise while the search moves to another tier
1385
+ let legacyCleanup = null; // Promise: the 0.4.2 single-scope registration, unregistered once
1386
+ let forcePageWorkers = false; // after a dead service-worker engine: this page uses page workers
1387
+ let pageGpu = null; // null (unknown) | false | { maxBufferSize, hasF16 }
1388
+ let pageGpuProbe = null;
720
1389
 
721
1390
  let agentInfo = null; // null (unknown) | false | { maxBufferSize, hasF16 }
722
- let agentWorker = null;
1391
+ let agent = null; // agent transport, null until the first Ask
1392
+ let agentCreating = null;
723
1393
  let agentModel = null; // { id, base, sizeBytes }
724
1394
  let agentLoaded = false;
725
1395
  let agentLoading = null; // Promise while a load is in flight
@@ -908,6 +1578,24 @@ const EddieLib = {};
908
1578
  transition: border-color 0.1s;
909
1579
  }
910
1580
  .sa-close:hover { border-color: var(--sa-text-muted); }
1581
+ .sa-gear svg { width: 15px; height: 15px; display: block; }
1582
+ .sa-gear[aria-expanded="true"] { border-color: var(--sa-accent); color: var(--sa-accent); }
1583
+
1584
+ .sa-set-body { display: flex; flex-direction: column; gap: 12px; }
1585
+ .sa-set-group { display: flex; flex-direction: column; gap: 4px; }
1586
+ .sa-set-group > .sa-set-legend {
1587
+ font-size: 11px;
1588
+ letter-spacing: 0.08em;
1589
+ text-transform: uppercase;
1590
+ color: var(--sa-text-muted);
1591
+ font-family: var(--sa-font-mono);
1592
+ }
1593
+ .sa-set-opt { display: flex; align-items: baseline; gap: 8px; cursor: pointer; padding: 2px 0; }
1594
+ .sa-set-opt input { margin: 0; flex-shrink: 0; accent-color: var(--sa-accent); }
1595
+ .sa-set-opt-main { font-size: 13px; }
1596
+ .sa-set-opt-detail { font-size: 12px; color: var(--sa-text-muted); }
1597
+ .sa-set-note { font-size: 12px; color: var(--sa-text-muted); }
1598
+ .sa-set-storage { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
911
1599
 
912
1600
  .sa-heart { flex-shrink: 0; width: 14px; height: 8px; image-rendering: pixelated; image-rendering: crisp-edges; opacity: 0.95; display: block; }
913
1601
 
@@ -1145,6 +1833,14 @@ const EddieLib = {};
1145
1833
  askBtn.hidden = true;
1146
1834
  header.appendChild(askBtn);
1147
1835
 
1836
+ const gearBtn = button("sa-close sa-gear", "", toggleSettings, {
1837
+ "aria-label": "Settings",
1838
+ "aria-expanded": "false",
1839
+ title: "Settings",
1840
+ });
1841
+ gearBtn.appendChild(createGearSvg());
1842
+ header.appendChild(gearBtn);
1843
+
1148
1844
  const closeBtn = button("sa-close", "esc", closeModal, { "aria-label": "Close (Esc)" });
1149
1845
  header.appendChild(closeBtn);
1150
1846
 
@@ -1190,6 +1886,22 @@ const EddieLib = {};
1190
1886
  consentCard.appendChild(consentActions);
1191
1887
  modal.appendChild(consentCard);
1192
1888
 
1889
+ // Settings panel (gear): visitor preferences over the site's data-* config.
1890
+ const settingsCard = el("div", "sa-card sa-settings");
1891
+ settingsCard.setAttribute("role", "group");
1892
+ settingsCard.setAttribute("aria-label", "Search settings");
1893
+ const settingsLabel = el("div", "sa-card-label", "Settings");
1894
+ const settingsBody = el("div", "sa-set-body");
1895
+ const settingsActions = el("div", "sa-card-actions");
1896
+ const settingsReset = button("sa-btn", "Use this site's defaults", resetSettings);
1897
+ const settingsDone = button("sa-btn sa-btn-primary", "Done", () => setSettingsOpen(false));
1898
+ settingsActions.appendChild(settingsDone);
1899
+ settingsActions.appendChild(settingsReset);
1900
+ settingsCard.appendChild(settingsLabel);
1901
+ settingsCard.appendChild(settingsBody);
1902
+ settingsCard.appendChild(settingsActions);
1903
+ modal.appendChild(settingsCard);
1904
+
1193
1905
  // Error area
1194
1906
  const errorEl = el("div", "sa-error");
1195
1907
  errorEl.setAttribute("role", "alert");
@@ -1324,7 +2036,9 @@ const EddieLib = {};
1324
2036
  modal.addEventListener("keydown", (e) => {
1325
2037
  if (e.key === "Escape") {
1326
2038
  e.preventDefault();
1327
- closeModal();
2039
+ // Esc backs out of the settings panel first, then closes the modal.
2040
+ if (settingsOpen) setSettingsOpen(false);
2041
+ else closeModal();
1328
2042
  return;
1329
2043
  }
1330
2044
  if (e.key !== "Tab") return;
@@ -1354,50 +2068,315 @@ const EddieLib = {};
1354
2068
  return true;
1355
2069
  }
1356
2070
 
1357
- // -- Search worker --
1358
- function ensureWorker() {
1359
- if (worker) {
1360
- if (workerState === "error") retryInit();
1361
- return;
1362
- }
2071
+ // -- Transports --
2072
+ // The decision (service worker or page workers) starts after `load`, from
2073
+ // an idle callback, so registration never delays the page. Modal opens
2074
+ // wait for it at most TRANSPORT_WAIT_MS; warm-up waits as long as it takes.
2075
+ function onIdle(fn) {
2076
+ if (typeof requestIdleCallback === "function") requestIdleCallback(() => fn(), { timeout: 2000 });
2077
+ else setTimeout(fn, 200);
2078
+ }
2079
+
2080
+ function afterLoad(fn) {
2081
+ if (document.readyState === "complete") fn();
2082
+ else window.addEventListener("load", () => fn(), { once: true });
2083
+ }
2084
+
2085
+ /**
2086
+ * The transport decision for one service worker tier (see
2087
+ * lib/transport.js): register that tier's worker and say hello, or fall
2088
+ * back to page workers. Made once per tier and only when something needs
2089
+ * it: a modal open (lite), a consent (dense/gpu), an Ask (agent) or a
2090
+ * returning visitor's warm-up. A plain page view registers nothing.
2091
+ */
2092
+ function ensureTransportPlan(tier) {
2093
+ const t = tier || "lite";
2094
+ if (!transportPlans[t]) transportPlans[t] = setupTransport(t);
2095
+ return transportPlans[t];
2096
+ }
2097
+
2098
+ async function setupTransport(tier) {
2099
+ const kind = lib.chooseTransportKind({
2100
+ persist: config.persist,
2101
+ hasServiceWorker: !!navigator.serviceWorker,
2102
+ secureContext: window.isSecureContext,
2103
+ });
2104
+ if (kind !== "sw" || forcePageWorkers) return { kind: "worker", tier };
2105
+ // The agent tier's channel speaks the agent protocol; every other tier
2106
+ // hosts the search engine.
2107
+ const channelKind = tier === "agent" ? "agent" : "search";
2108
+ let registration = null;
2109
+ let sw = null;
1363
2110
  try {
1364
- worker = new Worker(lib.assetUrl(baseUrl, "eddie-worker.js", version));
2111
+ if (!legacyCleanup) {
2112
+ legacyCleanup = lib.unregisterLegacyServiceWorker(navigator.serviceWorker, baseUrl).then((done) => {
2113
+ if (done) console.info("eddie: unregistered the 0.4.2 service worker (scope " + baseUrl + ")");
2114
+ });
2115
+ }
2116
+ await legacyCleanup;
2117
+ registration = await lib.registerServiceWorker({
2118
+ container: navigator.serviceWorker,
2119
+ url: lib.assetUrl(baseUrl, lib.swScriptName(tier), lib.ASSET_VERSION),
2120
+ scope: lib.swScope(baseUrl, tier),
2121
+ });
2122
+ sw = new lib.ServiceWorkerTransport(registration, { kind: channelKind, version });
2123
+ const hello = await sw.connect();
2124
+ if (hello.tier && hello.tier !== tier) throw new Error(`service worker at ${lib.swScope(baseUrl, tier)} reports tier ${hello.tier}`);
2125
+ // The gpu tier cannot run a webgpu-onnx lane without WebGPU in the
2126
+ // service worker's scope while this page could: search stays
2127
+ // page-side for quality, the agent still goes wherever the service
2128
+ // worker can host it.
2129
+ if (channelKind === "search" && lib.searchStaysOnPage({ tier, swOnnx: hello.onnx, pageHasGpu: !!(await probePageGpu()), denseRuntime: config.denseRuntime })) {
2130
+ sw.terminate();
2131
+ sw = null;
2132
+ }
2133
+ return { kind: "sw", tier, registration, hello, sw };
1365
2134
  } catch (err) {
1366
- showInitError("Couldn't start the search worker: " + (err && err.message ? err.message : err), false);
1367
- return;
2135
+ console.info(`eddie: ${tier} service worker unavailable, using page workers:`, err && err.message ? err.message : err);
2136
+ if (sw) sw.terminate();
2137
+ return { kind: "worker", tier };
2138
+ }
2139
+ }
2140
+
2141
+ function withDeadline(promise, ms) {
2142
+ return new Promise((resolve) => {
2143
+ const timer = setTimeout(() => resolve(null), ms);
2144
+ promise.then(
2145
+ (v) => {
2146
+ clearTimeout(timer);
2147
+ resolve(v);
2148
+ },
2149
+ () => {
2150
+ clearTimeout(timer);
2151
+ resolve(null);
2152
+ }
2153
+ );
2154
+ });
2155
+ }
2156
+
2157
+ function probePageGpu() {
2158
+ if (pageGpu !== null) return Promise.resolve(pageGpu);
2159
+ if (pageGpuProbe) return pageGpuProbe;
2160
+ pageGpuProbe = (async () => {
2161
+ pageGpu = false;
2162
+ if (!navigator.gpu || typeof navigator.gpu.requestAdapter !== "function") return pageGpu;
2163
+ try {
2164
+ const adapter = await navigator.gpu.requestAdapter();
2165
+ if (adapter) {
2166
+ pageGpu = {
2167
+ maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
2168
+ hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
2169
+ };
2170
+ }
2171
+ } catch (err) {
2172
+ console.warn("eddie: WebGPU adapter probe failed", err);
2173
+ }
2174
+ return pageGpu;
2175
+ })();
2176
+ return pageGpuProbe;
2177
+ }
2178
+
2179
+ // -- Search transport --
2180
+ /**
2181
+ * Create and wire the search transport for `tier` (no init yet). `waitMs`
2182
+ * bounds the transport decision; the tier defaults to the one a stored
2183
+ * consent remembers, else lite.
2184
+ */
2185
+ function ensureSearchTransport(waitMs, tier) {
2186
+ if (search) return Promise.resolve(search);
2187
+ if (searchCreating) return searchCreating;
2188
+ const t0 = tier || lib.searchTierFor({ rememberedTier: readSearchTier() });
2189
+ searchCreating = (async () => {
2190
+ const t = await createSearchTransport(t0, waitMs);
2191
+ if (search) return search;
2192
+ if (!t) return null;
2193
+ attachSearch(t.transport, t.plan, t0);
2194
+ return t.transport;
2195
+ })().finally(() => {
2196
+ searchCreating = null;
2197
+ });
2198
+ return searchCreating;
2199
+ }
2200
+
2201
+ /** A transport for `tier`: the tier's service worker channel, else a page worker. */
2202
+ async function createSearchTransport(tier, waitMs) {
2203
+ const planPromise = ensureTransportPlan(tier);
2204
+ const plan = waitMs == null ? await planPromise.catch(() => null) : await withDeadline(planPromise, waitMs);
2205
+ let t = plan && plan.kind === "sw" && plan.sw && !forcePageWorkers ? plan.sw : null;
2206
+ if (t && t.closed) t = null;
2207
+ if (!t) {
2208
+ try {
2209
+ t = new lib.DedicatedWorkerTransport(lib.assetUrl(baseUrl, "eddie-worker.js", lib.ASSET_VERSION));
2210
+ } catch (err) {
2211
+ showInitError("Couldn't start the search worker: " + (err && err.message ? err.message : err), false);
2212
+ return null;
2213
+ }
1368
2214
  }
1369
- worker.onmessage = (e) => onWorkerMessage(e.data || {});
1370
- worker.onerror = (e) => {
2215
+ return { transport: t, plan };
2216
+ }
2217
+
2218
+ /**
2219
+ * Move the search engine to the tier that can host the lane the visitor
2220
+ * just accepted (a lite service worker has no candle, no transformers.js).
2221
+ * Page workers load what they need themselves and never move. The old
2222
+ * channel is dropped; Chrome stops the idle worker on its own.
2223
+ */
2224
+ function switchSearchTier(tier) {
2225
+ if (!search || search.kind !== "sw" || tier === searchTier) return Promise.resolve();
2226
+ if (switching) return switching;
2227
+ switching = (async () => {
2228
+ const made = await createSearchTransport(tier, null);
2229
+ if (!made || !search) return;
2230
+ const old = search;
2231
+ try {
2232
+ old.postRaw({ type: "disconnect" });
2233
+ } catch (_) {
2234
+ // port already gone
2235
+ }
2236
+ old.terminate();
2237
+ search = null;
2238
+ searchable = false;
2239
+ initSent = false;
2240
+ setWorkerState("idle");
2241
+ attachSearch(made.transport, made.plan, tier);
2242
+ console.info(`eddie: search moved to the ${tier} tier (${made.transport.kind})`);
2243
+ })().finally(() => {
2244
+ switching = null;
2245
+ });
2246
+ return switching;
2247
+ }
2248
+
2249
+ function attachSearch(t, plan, tier) {
2250
+ search = t;
2251
+ searchTier = tier || "lite";
2252
+ initSent = false;
2253
+ host.dataset.transport = t.kind;
2254
+ host.dataset.tier = searchTier;
2255
+ t.on("status", handleStatus);
2256
+ t.on("ready", handleReady);
2257
+ t.on("error", (msg) => {
2258
+ if (msg.requestId != null) return; // request errors reject their promise
2259
+ if (msg.fatal) onFatal(msg.message);
2260
+ else showInitError(msg.message || "Search failed", false);
2261
+ });
2262
+ t.on("crash", (msg) => {
1371
2263
  // An uncaught error in the worker script (a 404, a syntax error, an
1372
- // exception outside a handler): start a fresh worker on Retry.
1373
- const message = e && e.message ? e.message : "search worker failed to load";
2264
+ // exception outside a handler) or a service worker that could not be
2265
+ // reconnected: start afresh on Retry.
1374
2266
  setWorkerState("dead");
1375
2267
  searchable = false;
1376
2268
  showStatus(false);
1377
- showInitError(message, true);
1378
- failAllPending(new Error(message));
2269
+ showInitError(msg.message || "search worker failed to load", true);
2270
+ failInitWaiters(new Error(msg.message || "search worker failed"));
2271
+ });
2272
+ t.on("reset", () => {
2273
+ // The service worker was stopped while idle and reconnected: its engine
2274
+ // is empty again. Re-run init transparently when the modal is open;
2275
+ // otherwise the next open does it.
2276
+ console.info("eddie: search engine restarted");
2277
+ searchable = false;
2278
+ initSent = false;
2279
+ setWorkerState("idle");
2280
+ host.dataset.arms = "";
2281
+ host.dataset.lane = "";
2282
+ host.dataset.runtime = "";
2283
+ if (isOpen) postInit(false);
2284
+ });
2285
+ if (t.kind === "sw" && plan && plan.hello && plan.hello.search) {
2286
+ // A worker that already holds the index does not re-post index_ready on
2287
+ // a second init, so its snapshot is where a fresh page learns the lanes.
2288
+ rememberLanes(plan.hello.search);
2289
+ if (lib.canReuseSearch(plan.hello.search, indexUrl) && runsPreferredLane(plan.hello.search)) {
2290
+ adoptReadyState(plan.hello.search);
2291
+ }
2292
+ }
2293
+ }
2294
+
2295
+ /**
2296
+ * Is a ready engine running the lane this visitor asked for? A worker left
2297
+ * over from before a settings change is ready for the right index but the
2298
+ * wrong lane, and adopting it would silently ignore the preference.
2299
+ */
2300
+ function runsPreferredLane(st) {
2301
+ if (config.denseRuntime === "off") return !st.lane;
2302
+ if (config.laneId) return st.lane === config.laneId;
2303
+ return true;
2304
+ }
2305
+
2306
+ /** The service worker already holds a ready engine for this index: mirror it. */
2307
+ function adoptReadyState(st) {
2308
+ initSent = true;
2309
+ handleReady({
2310
+ type: "ready",
2311
+ lanes: st.lanes || [],
2312
+ lane: st.lane,
2313
+ runtime: st.runtime,
2314
+ arms: st.arms || { dense: false, sparse: false, bm25: true },
2315
+ degraded: st.degraded || [],
2316
+ manifest: st.manifest,
2317
+ reused: true,
2318
+ });
2319
+ }
2320
+
2321
+ /** Modal-open path: make sure a transport exists and an init is under way. */
2322
+ async function ensureWorker() {
2323
+ if (search) {
2324
+ if (workerState === "error") {
2325
+ retryInit();
2326
+ return;
2327
+ }
2328
+ // With the modal closed nothing pinged the service worker, so Chrome
2329
+ // may have stopped it: a failed ping reconnects and emits reset,
2330
+ // which re-sends init while the modal is open. A worker that is
2331
+ // streaming load status is alive by definition (and may be too busy
2332
+ // to answer a ping in time).
2333
+ if (search.kind === "sw" && initSent && workerState !== "loading") await search.ensureAlive(lib.IDLE_PING_TIMEOUT_MS);
2334
+ if (search && !initSent) postInit(false);
2335
+ return;
2336
+ }
2337
+ const t = await ensureSearchTransport(TRANSPORT_WAIT_MS);
2338
+ if (!t) return;
2339
+ if (!initSent) postInit(false);
2340
+ }
2341
+
2342
+ /** The "Search model" group is built from this; every engine message that carries a lane list refreshes it. */
2343
+ function rememberLanes(msg) {
2344
+ if (!msg || !Array.isArray(msg.lanes)) return;
2345
+ laneInfo = {
2346
+ lanes: msg.lanes,
2347
+ hostSkipped: Array.isArray(msg.hostSkippedLanes) ? msg.hostSkippedLanes : [],
1379
2348
  };
1380
- postInit(false);
2349
+ if (settingsOpen) currentChoices().then(renderSettings);
1381
2350
  }
1382
2351
 
1383
2352
  function postInit(consent) {
2353
+ if (!search) return;
2354
+ initSent = true;
1384
2355
  setWorkerState("loading");
1385
2356
  hide(errorEl);
1386
- worker.postMessage({
2357
+ search.postRaw({
1387
2358
  type: "init",
1388
2359
  indexUrl,
1389
2360
  baseUrl,
1390
2361
  version,
1391
2362
  denseRuntime: config.denseRuntime,
2363
+ laneId: config.laneId || null,
1392
2364
  consent: !!consent,
2365
+ consentLane: consent ? pendingConsentLane : undefined,
1393
2366
  });
2367
+ updateKeepalive();
1394
2368
  }
1395
2369
 
1396
2370
  function retryInit() {
1397
2371
  hide(errorEl);
1398
- if (!worker || workerState === "dead") {
1399
- if (worker) worker.terminate();
1400
- worker = null;
2372
+ if (!search || workerState === "dead") {
2373
+ if (search) {
2374
+ // A dead engine inside the service worker stays dead until Chrome
2375
+ // restarts it; this page continues with page-side workers.
2376
+ if (search.kind === "sw") forcePageWorkers = true;
2377
+ search.terminate();
2378
+ }
2379
+ search = null;
1401
2380
  searchable = false;
1402
2381
  setWorkerState("idle");
1403
2382
  ensureWorker();
@@ -1406,80 +2385,112 @@ const EddieLib = {};
1406
2385
  postInit(false);
1407
2386
  }
1408
2387
 
1409
- function callWorker(type, payload) {
2388
+ /** Resolves once the index is loaded in the current transport. */
2389
+ function whenIndexLoaded() {
2390
+ if (searchable) return Promise.resolve();
1410
2391
  return new Promise((resolve, reject) => {
1411
- if (!worker) {
1412
- reject(new Error("search worker not running"));
1413
- return;
1414
- }
1415
- const requestId = ++requestSeq;
1416
- pending.set(requestId, { resolve, reject });
1417
- worker.postMessage(Object.assign({ type, requestId }, payload || {}));
2392
+ initWaiters.push({ resolve, reject });
1418
2393
  });
1419
2394
  }
1420
2395
 
1421
- function failAllPending(err) {
1422
- pending.forEach((p) => p.reject(err));
1423
- pending.clear();
2396
+ function resolveInitWaiters() {
2397
+ const waiters = initWaiters;
2398
+ initWaiters = [];
2399
+ waiters.forEach((w) => w.resolve());
1424
2400
  }
1425
2401
 
1426
- function onWorkerMessage(msg) {
1427
- if (msg.requestId != null && pending.has(msg.requestId)) {
1428
- const p = pending.get(msg.requestId);
1429
- pending.delete(msg.requestId);
1430
- if (msg.type === "error") {
1431
- const err = new Error(msg.message || "request failed");
1432
- err.fatal = !!msg.fatal;
1433
- p.reject(err);
1434
- if (msg.fatal) onFatal(msg.message);
1435
- } else {
1436
- p.resolve(msg);
2402
+ function failInitWaiters(err) {
2403
+ const waiters = initWaiters;
2404
+ initWaiters = [];
2405
+ waiters.forEach((w) => w.reject(err));
2406
+ }
2407
+
2408
+ /**
2409
+ * A request to the search engine. A "not initialised" reply (the service
2410
+ * worker restarted, or a page-side worker that never got init) re-runs the
2411
+ * init flow once and retries.
2412
+ */
2413
+ async function callWorker(type, payload) {
2414
+ if (!search) throw new Error("search worker not running");
2415
+ const requestId = ++requestSeq;
2416
+ // A service worker that died mid-request never answers: bound the wait
2417
+ // and let the retry below reconnect.
2418
+ const opts = { requestId, timeoutMs: search.kind === "sw" ? SW_CALL_TIMEOUT_MS : 0 };
2419
+ try {
2420
+ return await search.call(type, payload, opts);
2421
+ } catch (err) {
2422
+ if (!err || err.fatal || !search) throw err;
2423
+ if (err.timeout) {
2424
+ if (!(await search.ensureAlive())) throw err;
2425
+ } else if (!lib.isNotLoadedMessage(err.message) && !/service worker restarted/.test(err.message)) {
2426
+ throw err;
1437
2427
  }
1438
- return;
1439
- }
1440
- switch (msg.type) {
1441
- case "status":
1442
- handleStatus(msg);
1443
- break;
1444
- case "ready":
1445
- handleReady(msg);
1446
- break;
1447
- case "error":
1448
- if (msg.fatal) onFatal(msg.message);
1449
- else showInitError(msg.message || "Search failed", false);
1450
- break;
1451
- default:
1452
- break;
2428
+ if (!search) throw err;
2429
+ if (!initSent || workerState === "idle") postInit(false);
2430
+ await whenIndexLoaded();
2431
+ return search.call(type, payload, { requestId: ++requestSeq, timeoutMs: opts.timeoutMs });
1453
2432
  }
1454
2433
  }
1455
2434
 
2435
+ function updateKeepalive() {
2436
+ const wanted = lib.keepaliveWanted({
2437
+ open: isOpen,
2438
+ streaming: !!(agentRun && !agentRun.done),
2439
+ pending: !!(search && search.pending && search.pending.size > 0),
2440
+ });
2441
+ if (search) search.setKeepalive(wanted);
2442
+ if (agent) agent.setKeepalive(wanted);
2443
+ }
2444
+
1456
2445
  function onFatal(message) {
1457
2446
  setWorkerState("dead");
1458
2447
  searchable = false;
1459
2448
  showStatus(false);
1460
2449
  showInitError(message || "The search engine crashed.", true);
2450
+ failInitWaiters(new Error(message || "search engine crashed"));
1461
2451
  }
1462
2452
 
1463
2453
  function handleStatus(msg) {
1464
2454
  switch (msg.state) {
1465
2455
  case "loading_wasm":
1466
2456
  setWorkerState("loading");
1467
- setStatus("Loading search engine…", null);
2457
+ setStatus(msg.variant === "dense" ? "Loading the dense search engine…" : "Loading search engine…", null);
1468
2458
  announce("Loading search engine");
1469
2459
  break;
1470
2460
  case "loading_index":
1471
2461
  setStatus("Loading index…", msg.progress);
1472
2462
  break;
2463
+ case "loading_sidecar":
2464
+ setStatus("Loading index vectors…", msg.progress);
2465
+ break;
1473
2466
  case "index_ready":
1474
2467
  manifestInfo = msg.manifest || null;
2468
+ rememberLanes(msg);
1475
2469
  setWorkerState("index_ready");
1476
2470
  searchable = true;
1477
- setStatus("Loading search model…", null);
2471
+ if (initSent) setStatus("Loading search model…", null);
2472
+ resolveInitWaiters();
1478
2473
  rerunQuery();
1479
2474
  break;
2475
+ case "tier_required":
2476
+ // The engine's host cannot run the lane it chose (lite worker, cached
2477
+ // webgpu lane): move the search to the tier that can and init again.
2478
+ console.info(`eddie: ${msg.message || "tier change"}; moving search to the ${msg.tier} tier`);
2479
+ if (search && search.kind === "sw" && lib.SW_TIERS.includes(msg.tier)) {
2480
+ switchSearchTier(msg.tier).then(() => {
2481
+ if (search && !initSent) postInit(false);
2482
+ });
2483
+ } else {
2484
+ setWorkerState("error");
2485
+ showInitError(msg.message || "This host cannot run the search model", true);
2486
+ }
2487
+ break;
1480
2488
  case "consent_required":
1481
2489
  setWorkerState("awaiting_consent");
2490
+ rememberLanes(msg);
1482
2491
  showStatus(false);
2492
+ pendingConsentLane = msg.lane && msg.lane.id ? msg.lane.id : null;
2493
+ pendingConsentKind = msg.lane && msg.lane.kind ? msg.lane.kind : null;
1483
2494
  if (consentDeclined) break;
1484
2495
  showConsent(msg);
1485
2496
  break;
@@ -1503,6 +2514,7 @@ const EddieLib = {};
1503
2514
  setWorkerState(msg.fatal ? "dead" : "error");
1504
2515
  showStatus(false);
1505
2516
  showInitError(msg.message || "Failed to initialise search", !msg.unsupported);
2517
+ failInitWaiters(new Error(msg.message || "search failed to initialise"));
1506
2518
  break;
1507
2519
  default:
1508
2520
  break;
@@ -1513,15 +2525,130 @@ const EddieLib = {};
1513
2525
  setWorkerState("ready");
1514
2526
  searchable = true;
1515
2527
  manifestInfo = msg.manifest || manifestInfo;
2528
+ rememberLanes(msg);
1516
2529
  const arms = msg.arms || {};
1517
2530
  host.dataset.arms = Object.keys(arms).filter((k) => arms[k]).join(",");
1518
2531
  host.dataset.lane = msg.lane || "";
1519
2532
  host.dataset.runtime = msg.runtime || "";
2533
+ host.dataset.readyMs = String(Math.round(performance.now()));
2534
+ host.dataset.reused = msg.reused ? "true" : "false";
2535
+ host.dataset.wasm = msg.wasm || "";
2536
+ if (msg.lane) rememberSearchConsent(msg.lane, msg.runtime === "webgpu" ? "gpu" : "dense");
1520
2537
  showStatus(false);
1521
2538
  hide(consentCard);
1522
2539
  lastDegradedNotice = lib.degradedNotice(msg.arms, msg.degraded);
1523
2540
  announce(lastDegradedNotice ? "Search ready. " + lastDegradedNotice : "Search ready");
2541
+ resolveInitWaiters();
1524
2542
  rerunQuery();
2543
+ updateKeepalive();
2544
+ }
2545
+
2546
+ // -- Warm at load (data-warm) --
2547
+ // Runs after `load` and an idle callback. In "auto" only a returning
2548
+ // visitor (opened the search or accepted a model on this browser before)
2549
+ // gets a transport at all, and a model download only after a prior
2550
+ // consent with the files still cached; `always` is the site owner's
2551
+ // choice to warm uncached lanes too. A first-time visitor's page view
2552
+ // fetches nothing beyond this script.
2553
+ async function warmUp() {
2554
+ if (config.warm === "off" || search || isOpen) return;
2555
+ const rememberedTier = readSearchTier();
2556
+ let decision = lib.decideWarm({ mode: config.warm, saveData, engineReady: false, checked: false, returning: returningAtLoad });
2557
+ console.debug("eddie warm:", decision.action, decision.reason);
2558
+ if (decision.action === "none") return;
2559
+ const tier = lib.searchTierFor({ rememberedTier });
2560
+ const plan = await ensureTransportPlan(tier).catch(() => null);
2561
+ if (!plan || search || isOpen) return; // the modal opened first: the normal flow owns init
2562
+ const engineReady = plan.kind === "sw" && !!plan.sw && lib.canReuseSearch(plan.hello.search, indexUrl);
2563
+ decision = lib.decideWarm({ mode: config.warm, saveData, engineReady, checked: false, returning: true });
2564
+ console.debug("eddie warm:", decision.action, decision.reason);
2565
+ if (decision.action === "none") return;
2566
+ const t = await ensureSearchTransport(null, tier);
2567
+ if (!t) return;
2568
+ if (decision.action === "adopt") return; // attachSearch adopted the ready state
2569
+ if (initSent || isOpen) return;
2570
+ let cr;
2571
+ try {
2572
+ cr = await search.call("cache_check", { indexUrl, baseUrl, version, denseRuntime: config.denseRuntime }, { requestId: ++requestSeq, timeoutMs: 240000 });
2573
+ } catch (err) {
2574
+ console.info("eddie warm: cache check failed:", err && err.message ? err.message : err);
2575
+ return;
2576
+ }
2577
+ if (initSent || !search) return;
2578
+ decision = lib.decideWarm({
2579
+ mode: config.warm,
2580
+ saveData,
2581
+ engineReady: cr.phase === "ready",
2582
+ checked: true,
2583
+ returning: true,
2584
+ lane: cr.lane,
2585
+ cached: cr.cached,
2586
+ consentedLane: readSearchConsent(),
2587
+ });
2588
+ console.debug("eddie warm:", decision.action, decision.reason);
2589
+ if (decision.action === "init") {
2590
+ if (cr.lane) {
2591
+ pendingConsentLane = cr.lane.id;
2592
+ pendingConsentKind = cr.lane.kind || null;
2593
+ // The engine will load this lane: make sure the transport can host it.
2594
+ await switchSearchTier(lib.searchTierFor({ laneKind: cr.lane.kind }));
2595
+ if (!search) return;
2596
+ }
2597
+ postInit(decision.consent);
2598
+ } else if (decision.action === "adopt") {
2599
+ postInit(false); // the engine answers with ready at once
2600
+ }
2601
+ }
2602
+
2603
+ function readSearchConsent() {
2604
+ try {
2605
+ return localStorage.getItem(SEARCH_CONSENT_KEY) || null;
2606
+ } catch (_) {
2607
+ return null;
2608
+ }
2609
+ }
2610
+
2611
+ function readSearchTier() {
2612
+ try {
2613
+ return localStorage.getItem(SEARCH_TIER_KEY) || null;
2614
+ } catch (_) {
2615
+ return null;
2616
+ }
2617
+ }
2618
+
2619
+ function searchUsedBefore() {
2620
+ try {
2621
+ return !!localStorage.getItem(SEARCH_USED_KEY);
2622
+ } catch (_) {
2623
+ return false;
2624
+ }
2625
+ }
2626
+
2627
+ function rememberSearchUsed() {
2628
+ try {
2629
+ localStorage.setItem(SEARCH_USED_KEY, "1");
2630
+ } catch (_) {
2631
+ // storage unavailable: every visit is a first visit
2632
+ }
2633
+ }
2634
+
2635
+ /** The tier the next page should register directly. "lite" is the default, so it clears the key. */
2636
+ function rememberSearchTier(tier) {
2637
+ try {
2638
+ if (tier && tier !== "lite") localStorage.setItem(SEARCH_TIER_KEY, tier);
2639
+ else localStorage.removeItem(SEARCH_TIER_KEY);
2640
+ } catch (_) {
2641
+ // storage unavailable: the next page works it out again
2642
+ }
2643
+ }
2644
+
2645
+ function rememberSearchConsent(laneId, tier) {
2646
+ try {
2647
+ localStorage.setItem(SEARCH_CONSENT_KEY, String(laneId));
2648
+ if (tier) localStorage.setItem(SEARCH_TIER_KEY, String(tier));
2649
+ } catch (_) {
2650
+ // storage unavailable: no warm-up next visit
2651
+ }
1525
2652
  }
1526
2653
 
1527
2654
  function rerunQuery() {
@@ -1537,20 +2664,267 @@ const EddieLib = {};
1537
2664
  consentText.textContent = lib.consentCopy({
1538
2665
  sizeBytes: msg.sizeBytes,
1539
2666
  model: short,
2667
+ origin: msg.origin || (lane.origin || "huggingface"),
2668
+ sidecarBytes: msg.sidecarBytes || 0,
1540
2669
  saveData: saveData || !!msg.saveData,
1541
2670
  consentText: config.consentText,
1542
2671
  });
1543
2672
  consentAccept.textContent = msg.sizeBytes == null ? "Download" : `Download ${lib.formatBytes(msg.sizeBytes)}`;
2673
+ if (settingsOpen) setSettingsOpen(false);
1544
2674
  show(consentCard);
1545
2675
  announce("Semantic search needs a model download. " + consentText.textContent);
1546
2676
  }
1547
2677
 
1548
- function acceptConsent() {
2678
+ async function acceptConsent() {
1549
2679
  hide(consentCard);
1550
- if (!worker) return;
1551
- setWorkerState("loading");
1552
- worker.postMessage({ type: "init", indexUrl, baseUrl, version, denseRuntime: config.denseRuntime, consent: true });
2680
+ if (!search) return;
2681
+ const tier = lib.searchTierFor({ laneKind: pendingConsentKind });
2682
+ if (pendingConsentLane) rememberSearchConsent(pendingConsentLane, tier === "lite" ? null : tier);
1553
2683
  input.focus();
2684
+ setStatus("Loading search model…", null);
2685
+ // The lite service worker cannot host the lane: move first, then init
2686
+ // with consent on the tier that can.
2687
+ await switchSearchTier(tier);
2688
+ if (!search) return;
2689
+ postInit(true);
2690
+ }
2691
+
2692
+ // -- Settings (gear) --
2693
+ // Four preferences in one localStorage entry (lib/settings.js), each
2694
+ // bounded by the site's data-* config.
2695
+
2696
+ function createGearSvg() {
2697
+ const ns = "http://www.w3.org/2000/svg";
2698
+ const svg = document.createElementNS(ns, "svg");
2699
+ svg.setAttribute("viewBox", "0 0 16 16");
2700
+ svg.setAttribute("aria-hidden", "true");
2701
+ svg.setAttribute("fill", "none");
2702
+ svg.setAttribute("stroke", "currentColor");
2703
+ svg.setAttribute("stroke-width", "1.3");
2704
+ svg.setAttribute("stroke-linecap", "round");
2705
+ const circle = document.createElementNS(ns, "circle");
2706
+ circle.setAttribute("cx", "8");
2707
+ circle.setAttribute("cy", "8");
2708
+ circle.setAttribute("r", "2.4");
2709
+ const teeth = document.createElementNS(ns, "path");
2710
+ teeth.setAttribute(
2711
+ "d",
2712
+ "M8 1v1.8M8 13.2V15M1 8h1.8M13.2 8H15M3.05 3.05l1.27 1.27M11.68 11.68l1.27 1.27M12.95 3.05l-1.27 1.27M4.32 11.68l-1.27 1.27"
2713
+ );
2714
+ svg.appendChild(circle);
2715
+ svg.appendChild(teeth);
2716
+ return svg;
2717
+ }
2718
+
2719
+ /** What the panel may offer: needs the lane list and an adapter probe. */
2720
+ async function currentChoices() {
2721
+ const gpu = await probePageGpu();
2722
+ return lib.settingsChoices({
2723
+ config: siteConfig,
2724
+ lanes: laneInfo ? laneInfo.lanes : null,
2725
+ hostSkipped: laneInfo ? laneInfo.hostSkipped : [],
2726
+ hasWebGpu: !!gpu,
2727
+ });
2728
+ }
2729
+
2730
+ function toggleSettings() {
2731
+ setSettingsOpen(!settingsOpen);
2732
+ }
2733
+
2734
+ async function setSettingsOpen(open) {
2735
+ settingsOpen = !!open;
2736
+ gearBtn.setAttribute("aria-expanded", settingsOpen ? "true" : "false");
2737
+ if (!settingsOpen) {
2738
+ hide(settingsCard);
2739
+ input.focus();
2740
+ return;
2741
+ }
2742
+ show(settingsCard);
2743
+ renderSettings(await currentChoices());
2744
+ const first = settingsCard.querySelector("input, button");
2745
+ if (first) first.focus();
2746
+ }
2747
+
2748
+ /** One radio group, or a muted line when the site and the browser leave only one answer. */
2749
+ function settingsGroup(legend, name, options, current, onPick) {
2750
+ if (!options || options.length === 0) return null;
2751
+ const group = el("div", "sa-set-group");
2752
+ group.appendChild(el("div", "sa-set-legend", legend));
2753
+ if (options.length === 1) {
2754
+ const only = options[0];
2755
+ group.appendChild(el("div", "sa-set-note", `${only.label} — ${only.detail}`));
2756
+ return group;
2757
+ }
2758
+ for (const opt of options) {
2759
+ const row = el("label", "sa-set-opt");
2760
+ const radio = document.createElement("input");
2761
+ radio.type = "radio";
2762
+ radio.name = "eddie-" + name;
2763
+ radio.value = opt.value;
2764
+ radio.checked = opt.value === current;
2765
+ radio.addEventListener("change", () => {
2766
+ if (radio.checked) onPick(opt.value);
2767
+ });
2768
+ const text = el("span");
2769
+ text.appendChild(el("span", "sa-set-opt-main", opt.label));
2770
+ if (opt.detail) {
2771
+ text.appendChild(document.createTextNode(" "));
2772
+ text.appendChild(el("span", "sa-set-opt-detail", opt.detail));
2773
+ }
2774
+ row.appendChild(radio);
2775
+ row.appendChild(text);
2776
+ group.appendChild(row);
2777
+ }
2778
+ return group;
2779
+ }
2780
+
2781
+ function renderSettings(choices) {
2782
+ const sel = lib.currentSelection(choices, settings, {
2783
+ config: siteConfig,
2784
+ activeLane: host.dataset.lane || null,
2785
+ activeAgent: agentModel ? (/-0\.8B$/i.test(agentModel.base) ? "light" : "quality") : null,
2786
+ });
2787
+ settingsBody.textContent = "";
2788
+ const groups = [
2789
+ settingsGroup("Search model", "searchLane", choices.search, sel.searchLane, pickSearchLane),
2790
+ settingsGroup("Answers", "agentLevel", choices.agent, sel.agentLevel, pickAgentLevel),
2791
+ settingsGroup("Preload", "warm", choices.warm, sel.warm, (v) => pickSimple("warm", v)),
2792
+ settingsGroup("Between pages", "persist", choices.persist, sel.persist, (v) => pickSimple("persist", v)),
2793
+ ];
2794
+ for (const g of groups) {
2795
+ if (g) settingsBody.appendChild(g);
2796
+ }
2797
+ settingsBody.appendChild(storageSection());
2798
+ }
2799
+
2800
+ /**
2801
+ * Quota-managed storage this origin holds: the model cache, WebLLM's
2802
+ * weights, whatever the service worker cached. Not the HTTP cache.
2803
+ */
2804
+ function storageSection() {
2805
+ const wrap = el("div", "sa-set-group");
2806
+ wrap.appendChild(el("div", "sa-set-legend", "Downloads on this device"));
2807
+ const row = el("div", "sa-set-storage");
2808
+ const size = el("span", "sa-set-opt-detail", "measuring…");
2809
+ const clear = button("sa-btn", "Delete downloads", () => clearDownloads(size));
2810
+ row.appendChild(size);
2811
+ row.appendChild(clear);
2812
+ wrap.appendChild(row);
2813
+ showStorageUsage(size);
2814
+ return wrap;
2815
+ }
2816
+
2817
+ async function showStorageUsage(target) {
2818
+ let text = "size unavailable in this browser";
2819
+ try {
2820
+ if (navigator.storage && navigator.storage.estimate) {
2821
+ const { usage } = await navigator.storage.estimate();
2822
+ text = Number(usage) > 0 ? lib.formatBytes(Number(usage)) : "nothing stored";
2823
+ }
2824
+ } catch (_) {
2825
+ // leave the fallback text
2826
+ }
2827
+ target.textContent = text;
2828
+ }
2829
+
2830
+ /**
2831
+ * Delete every downloaded model. While a transport exists the engine holds
2832
+ * the database open and deleteDatabase would block, so ask the engine;
2833
+ * otherwise delete it here.
2834
+ */
2835
+ async function clearDownloads(target) {
2836
+ target.textContent = "deleting…";
2837
+ try {
2838
+ if (search) {
2839
+ await search.call("cache_clear", {}, { requestId: ++requestSeq, timeoutMs: 60000 });
2840
+ } else if (window.indexedDB) {
2841
+ await new Promise((resolve) => {
2842
+ const req = indexedDB.deleteDatabase("eddie-models");
2843
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
2844
+ });
2845
+ }
2846
+ if (window.caches && caches.keys) {
2847
+ for (const name of await caches.keys()) {
2848
+ if (/webllm|mlc/i.test(name)) await caches.delete(name);
2849
+ }
2850
+ }
2851
+ try {
2852
+ localStorage.removeItem(AGENT_CONSENT_KEY);
2853
+ } catch (_) {
2854
+ // nothing to forget
2855
+ }
2856
+ } catch (err) {
2857
+ console.warn("eddie: could not delete downloads", err);
2858
+ }
2859
+ showStorageUsage(target);
2860
+ announce("Downloads deleted.");
2861
+ }
2862
+
2863
+ function saveSetting(patch) {
2864
+ settings = lib.writeSettings(localStore(), patch);
2865
+ return settings;
2866
+ }
2867
+
2868
+ /** Re-derive the effective config after a preference changed. */
2869
+ async function applySettings() {
2870
+ config = lib.effectiveConfig(siteConfig, settings, await currentChoices());
2871
+ }
2872
+
2873
+ async function pickSearchLane(value) {
2874
+ saveSetting({ searchLane: value });
2875
+ await applySettings();
2876
+ consentDeclined = false;
2877
+ if (value === "none") {
2878
+ // Forget the remembered lane so a later page does not warm it up again.
2879
+ try {
2880
+ localStorage.removeItem(SEARCH_CONSENT_KEY);
2881
+ } catch (_) {
2882
+ // nothing to forget
2883
+ }
2884
+ }
2885
+ // The chosen lane may need a different tier than the one hosting search,
2886
+ // and the next page should register that tier rather than the old one.
2887
+ const lane = (laneInfo ? laneInfo.lanes : []).find((l) => l.id === value) || null;
2888
+ const tier = lib.searchTierFor({ laneKind: lane ? lane.kind : null });
2889
+ rememberSearchTier(tier);
2890
+ if (!search) return;
2891
+ await switchSearchTier(tier);
2892
+ if (!search) return;
2893
+ pendingConsentLane = value === "none" ? null : value;
2894
+ pendingConsentKind = lane ? lane.kind : null;
2895
+ postInit(value !== "none");
2896
+ }
2897
+
2898
+ /** Forget the chosen agent so the next Ask re-detects it at the new level. */
2899
+ async function reselectAgent() {
2900
+ agentInfo = null;
2901
+ agentModel = null;
2902
+ askBtn.hidden = true;
2903
+ askHint.hidden = true;
2904
+ if (agent) {
2905
+ agent.terminate();
2906
+ agent = null;
2907
+ }
2908
+ await detectAgent();
2909
+ }
2910
+
2911
+ async function pickAgentLevel(value) {
2912
+ saveSetting({ agentLevel: value });
2913
+ await applySettings();
2914
+ await reselectAgent();
2915
+ }
2916
+
2917
+ async function pickSimple(field, value) {
2918
+ saveSetting({ [field]: value });
2919
+ await applySettings();
2920
+ }
2921
+
2922
+ async function resetSettings() {
2923
+ settings = lib.clearSettings(localStore());
2924
+ await applySettings();
2925
+ await reselectAgent();
2926
+ renderSettings(await currentChoices());
2927
+ announce("Settings reset to this site's defaults.");
1554
2928
  }
1555
2929
 
1556
2930
  function declineConsent() {
@@ -1578,32 +2952,27 @@ const EddieLib = {};
1578
2952
  }
1579
2953
 
1580
2954
  async function doSearch(query, explicit) {
1581
- if (!searchable || !worker) return;
2955
+ if (!searchable || !search) return;
1582
2956
  // A different query cancels a running answer; re-searching the question
1583
2957
  // being answered (a late debounce, a re-run after init) does not.
1584
2958
  if (agentRun && !agentRun.done && query !== agentRun.question) abortAgent("new search");
1585
2959
  lastSearchedQuery = query;
1586
- const requestId = ++requestSeq;
1587
- activeSearchId = requestId;
1588
- pending.set(requestId, {
1589
- resolve: (msg) => {
1590
- if (requestId !== activeSearchId) return;
1591
- renderSearchResult(msg, query);
1592
- },
1593
- reject: (err) => {
1594
- if (requestId !== activeSearchId) return;
1595
- showSearchError(err.message || "Search failed");
1596
- },
1597
- });
1598
- worker.postMessage({
1599
- type: "search",
1600
- requestId,
1601
- query,
1602
- topK: config.topK,
1603
- mode: "hybrid",
1604
- qa: wantQa(query) ? Math.max(1, Math.min(config.answerTopK, 3)) : 0,
1605
- explicit: !!explicit,
1606
- });
2960
+ const token = ++requestSeq;
2961
+ activeSearchId = token;
2962
+ try {
2963
+ const msg = await callWorker("search", {
2964
+ query,
2965
+ topK: config.topK,
2966
+ mode: "hybrid",
2967
+ qa: wantQa(query) ? Math.max(1, Math.min(config.answerTopK, 3)) : 0,
2968
+ explicit: !!explicit,
2969
+ });
2970
+ if (token !== activeSearchId) return;
2971
+ renderSearchResult(msg, query);
2972
+ } catch (err) {
2973
+ if (token !== activeSearchId) return;
2974
+ showSearchError((err && err.message) || "Search failed");
2975
+ }
1607
2976
  }
1608
2977
 
1609
2978
  function renderSearchResult(msg, query) {
@@ -1659,8 +3028,7 @@ const EddieLib = {};
1659
3028
  return;
1660
3029
  }
1661
3030
  const best = hits[0];
1662
- const threshold = config.qaMode === "always" ? 0 : 0.5;
1663
- if (!best || typeof best.score !== "number" || best.score < threshold) {
3031
+ if (!lib.faqPasses(best, config.qaMode)) {
1664
3032
  hide(faqCard);
1665
3033
  return;
1666
3034
  }
@@ -1761,21 +3129,13 @@ const EddieLib = {};
1761
3129
  // -- Agent --
1762
3130
  async function detectAgent() {
1763
3131
  if (agentInfo !== null) return agentInfo;
1764
- agentInfo = false;
1765
- if (config.agentMode === "off" || !navigator.gpu || typeof navigator.gpu.requestAdapter !== "function") {
3132
+ if (config.agentMode === "off") {
3133
+ agentInfo = false;
1766
3134
  return agentInfo;
1767
3135
  }
1768
- try {
1769
- const adapter = await navigator.gpu.requestAdapter();
1770
- if (adapter) {
1771
- agentInfo = {
1772
- maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
1773
- hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
1774
- };
1775
- }
1776
- } catch (err) {
1777
- console.warn("eddie: WebGPU adapter probe failed", err);
1778
- }
3136
+ const gpu = await probePageGpu();
3137
+ if (agentInfo !== null) return agentInfo;
3138
+ agentInfo = gpu ? { maxBufferSize: gpu.maxBufferSize, hasF16: gpu.hasF16 } : false;
1779
3139
  if (agentInfo) {
1780
3140
  agentModel = lib.selectAgentModel({
1781
3141
  mode: config.agentModel,
@@ -1886,7 +3246,12 @@ const EddieLib = {};
1886
3246
  const evidence = lib.mergeEvidence(lists, 6);
1887
3247
  await expandShortEvidence(evidence);
1888
3248
  if (run.aborted) return;
1889
- run.evidence = evidence.map((r) => ({ title: r.title, url: r.url, text: r.text || r.snippet || "" }));
3249
+ // Confident FAQ entries (build-time QA lane) go first: they are short,
3250
+ // direct, and already carry a source page.
3251
+ const faqHits = await callWorker("qa", { query: question, k: 3 }).then((m) => m.hits || []).catch(() => []);
3252
+ if (run.aborted) return;
3253
+ const faqItems = lib.qaEvidence(faqHits, 2);
3254
+ run.evidence = faqItems.concat(evidence.map((r) => ({ title: r.title, url: r.url, text: r.text || r.snippet || "" })));
1890
3255
  answerProgress.textContent = "Answering…";
1891
3256
  await streamAnswer(run);
1892
3257
  } catch (err) {
@@ -1921,12 +3286,41 @@ const EddieLib = {};
1921
3286
  });
1922
3287
  }
1923
3288
 
1924
- function ensureAgentWorker() {
1925
- if (agentWorker) return;
1926
- agentWorker = new Worker(lib.assetUrl(baseUrl, "eddie-agent-worker.js", version), { type: "module" });
1927
- agentWorker.onmessage = (e) => onAgentMessage(e.data || {});
1928
- agentWorker.onerror = (e) => {
1929
- const message = e && e.message ? e.message : "agent worker failed to load";
3289
+ /** The agent transport: the service worker when it has WebGPU, else a page-side module worker. */
3290
+ function ensureAgentTransport() {
3291
+ if (agent) return Promise.resolve(agent);
3292
+ if (agentCreating) return agentCreating;
3293
+ agentCreating = (async () => {
3294
+ // The agent always lives in the agent tier (WebLLM is only imported
3295
+ // there), whose worker is registered here, on the first Ask after
3296
+ // consent: the search tiers never fetch WebLLM.
3297
+ const plan = await withDeadline(ensureTransportPlan("agent"), TRANSPORT_WAIT_MS);
3298
+ if (agent) return agent;
3299
+ let t = null;
3300
+ if (plan && plan.kind === "sw" && plan.hello && plan.hello.gpu && plan.sw && !plan.sw.closed && !forcePageWorkers) {
3301
+ t = plan.sw;
3302
+ } else if (plan && plan.sw) {
3303
+ // Connected, but unusable for the agent (no WebGPU in the worker's scope).
3304
+ console.info("eddie: agent falls back to a page worker");
3305
+ plan.sw.terminate();
3306
+ }
3307
+ if (!t) t = new lib.DedicatedWorkerTransport(lib.assetUrl(baseUrl, "eddie-agent-worker.js", lib.ASSET_VERSION), { type: "module" });
3308
+ attachAgent(t);
3309
+ return t;
3310
+ })().finally(() => {
3311
+ agentCreating = null;
3312
+ });
3313
+ return agentCreating;
3314
+ }
3315
+
3316
+ function attachAgent(t) {
3317
+ agent = t;
3318
+ host.dataset.agentTransport = t.kind;
3319
+ for (const type of ["progress", "loaded", "plan_result", "token", "done", "aborted", "error"]) {
3320
+ t.on(type, onAgentMessage);
3321
+ }
3322
+ t.on("crash", (msg) => {
3323
+ const message = msg.message || "agent worker failed to load";
1930
3324
  if (agentPendingLoad) {
1931
3325
  agentPendingLoad.reject(new Error(message));
1932
3326
  agentPendingLoad = null;
@@ -1935,30 +3329,67 @@ const EddieLib = {};
1935
3329
  agentRun.done = true;
1936
3330
  showAnswerError(message, agentRun.question);
1937
3331
  }
1938
- };
3332
+ });
3333
+ t.on("reset", () => {
3334
+ // The service worker restarted: its model is gone; the next Ask loads again.
3335
+ console.info("eddie: agent engine restarted");
3336
+ agentLoaded = false;
3337
+ if (agentPendingLoad) {
3338
+ agentPendingLoad.reject(new Error("the answer engine restarted"));
3339
+ agentPendingLoad = null;
3340
+ }
3341
+ if (agentRun && !agentRun.done) {
3342
+ agentRun.done = true;
3343
+ if (agentRun.rejectStream) agentRun.rejectStream(new Error("the answer engine restarted"));
3344
+ }
3345
+ });
1939
3346
  }
1940
3347
 
1941
3348
  function loadAgent(run) {
1942
3349
  if (agentLoaded) return Promise.resolve();
1943
3350
  if (agentLoading) return agentLoading;
1944
- ensureAgentWorker();
1945
- agentLoading = new Promise((resolve, reject) => {
1946
- agentPendingLoad = { resolve, reject };
1947
- answerProgress.textContent = `Loading ${agentModel.base}…`;
1948
- announce(`Loading the answer model, ${agentModel.base}`);
1949
- agentWorker.postMessage({ type: "load", model: agentModel.id });
1950
- }).finally(() => {
3351
+ agentLoading = (async () => {
3352
+ const t = await ensureAgentTransport();
3353
+ if (run.aborted) return;
3354
+ if (t.kind === "sw") {
3355
+ // The service worker may still hold the model from an earlier page
3356
+ // (or may have been stopped meanwhile: reconnect first).
3357
+ try {
3358
+ if (!(await t.ensureAlive(lib.IDLE_PING_TIMEOUT_MS))) throw new Error("the answer engine is unreachable");
3359
+ const st = await t.state();
3360
+ if (lib.canReuseAgent(st.agent, agentModel.id)) {
3361
+ agentLoaded = true;
3362
+ host.dataset.agentReused = "true";
3363
+ console.info("eddie agent: reusing " + agentModel.id + " from the service worker");
3364
+ return;
3365
+ }
3366
+ } catch (err) {
3367
+ console.warn("eddie agent: state query failed", err);
3368
+ }
3369
+ }
3370
+ host.dataset.agentReused = "false";
3371
+ await new Promise((resolve, reject) => {
3372
+ agentPendingLoad = { resolve, reject };
3373
+ answerProgress.textContent = `Loading ${agentModel.base}…`;
3374
+ announce(`Loading the answer model, ${agentModel.base}`);
3375
+ t.postRaw({ type: "load", model: agentModel.id });
3376
+ });
3377
+ })().finally(() => {
1951
3378
  agentLoading = null;
1952
3379
  });
1953
3380
  return agentLoading;
1954
3381
  }
1955
3382
 
1956
- const agentPending = new Map();
1957
- function agentCall(type, payload) {
1958
- return new Promise((resolve, reject) => {
1959
- agentPending.set(payload.requestId, { resolve, reject });
1960
- agentWorker.postMessage(Object.assign({ type }, payload));
1961
- });
3383
+ /** A plan request; a "model not loaded" reply (service worker restarted) reloads once and retries. */
3384
+ async function agentCall(type, payload) {
3385
+ try {
3386
+ return await agent.call(type, payload, { requestId: payload.requestId });
3387
+ } catch (err) {
3388
+ if (!err || !lib.isModelNotLoadedMessage(err.message)) throw err;
3389
+ agentLoaded = false;
3390
+ await loadAgent({ aborted: false });
3391
+ return agent.call(type, payload, { requestId: ++requestSeq });
3392
+ }
1962
3393
  }
1963
3394
 
1964
3395
  function streamAnswer(run) {
@@ -1973,7 +3404,8 @@ const EddieLib = {};
1973
3404
  caret.setAttribute("aria-hidden", "true");
1974
3405
  run.caret = caret;
1975
3406
  answerText.appendChild(caret);
1976
- agentWorker.postMessage({ type: "ask", requestId: run.requestId, question: run.question, site: siteName, evidence: run.evidence });
3407
+ updateKeepalive();
3408
+ agent.postRaw({ type: "ask", requestId: run.requestId, question: run.question, site: siteName, evidence: run.evidence });
1977
3409
  });
1978
3410
  }
1979
3411
 
@@ -2005,8 +3437,7 @@ const EddieLib = {};
2005
3437
  }
2006
3438
  break;
2007
3439
  case "plan_result":
2008
- resolveAgent(msg);
2009
- break;
3440
+ break; // settled by the transport's call()
2010
3441
  case "token":
2011
3442
  if (agentRun && msg.requestId === agentRun.requestId && !agentRun.done) {
2012
3443
  agentRun.text += msg.text;
@@ -2023,11 +3454,7 @@ const EddieLib = {};
2023
3454
  }
2024
3455
  break;
2025
3456
  case "error":
2026
- if (msg.requestId != null && agentPending.has(msg.requestId)) {
2027
- const p = agentPending.get(msg.requestId);
2028
- agentPending.delete(msg.requestId);
2029
- p.reject(new Error(msg.message || "agent request failed"));
2030
- } else if (agentPendingLoad) {
3457
+ if (agentPendingLoad && msg.requestId == null) {
2031
3458
  agentPendingLoad.reject(new Error(msg.message || "model load failed"));
2032
3459
  agentPendingLoad = null;
2033
3460
  } else if (agentRun && !agentRun.done && (msg.requestId == null || msg.requestId === agentRun.requestId)) {
@@ -2040,13 +3467,6 @@ const EddieLib = {};
2040
3467
  }
2041
3468
  }
2042
3469
 
2043
- function resolveAgent(msg) {
2044
- const p = agentPending.get(msg.requestId);
2045
- if (!p) return;
2046
- agentPending.delete(msg.requestId);
2047
- p.resolve(msg);
2048
- }
2049
-
2050
3470
  function finishAnswer(run, msg) {
2051
3471
  run.done = true;
2052
3472
  clearAnswerActions();
@@ -2079,6 +3499,7 @@ const EddieLib = {};
2079
3499
  run.usage = usage;
2080
3500
  announce(msg.nohit ? "No answer: the site doesn't cover that." : "Answer ready. " + msg.answer);
2081
3501
  if (run.resolveStream) run.resolveStream();
3502
+ updateKeepalive();
2082
3503
  console.info("eddie agent " + JSON.stringify({
2083
3504
  question: run.question,
2084
3505
  model: agentModel ? agentModel.id : null,
@@ -2089,7 +3510,10 @@ const EddieLib = {};
2089
3510
  totalMs: usage.totalMs,
2090
3511
  tps: usage.tps,
2091
3512
  sinceAskMs: Math.round(performance.now() - run.started),
3513
+ agentReused: host.dataset.agentReused === "true",
3514
+ transport: agent ? agent.kind : null,
2092
3515
  }));
3516
+ host.dataset.agentDoneMs = String(Math.round(performance.now()));
2093
3517
  }
2094
3518
 
2095
3519
  function renderAnswerText(text, citations) {
@@ -2119,10 +3543,9 @@ const EddieLib = {};
2119
3543
  console.debug("eddie agent abort " + reason + " " + run.requestId);
2120
3544
  run.aborted = true;
2121
3545
  run.done = true;
2122
- if (agentWorker) agentWorker.postMessage({ type: "abort", requestId: run.requestId });
2123
- agentPending.forEach((p) => p.reject(new Error("aborted")));
2124
- agentPending.clear();
3546
+ if (agent) agent.postRaw({ type: "abort", requestId: run.requestId });
2125
3547
  if (run.resolveStream) run.resolveStream();
3548
+ updateKeepalive();
2126
3549
  if (reason === "stopped") {
2127
3550
  paintStream(run, true);
2128
3551
  clearAnswerActions();
@@ -2154,7 +3577,9 @@ const EddieLib = {};
2154
3577
 
2155
3578
  // -- Modal open/close --
2156
3579
  function openModal() {
3580
+ if (isOpen) return;
2157
3581
  isOpen = true;
3582
+ rememberSearchUsed();
2158
3583
  rotateHeartSprite();
2159
3584
  backdrop.classList.add("sa-open");
2160
3585
  trigger.style.display = "none";
@@ -2164,17 +3589,20 @@ const EddieLib = {};
2164
3589
  hide(answerCard);
2165
3590
  ensureWorker();
2166
3591
  detectAgent();
3592
+ updateKeepalive();
2167
3593
  requestAnimationFrame(() => input.focus());
2168
3594
  }
2169
3595
 
2170
3596
  function closeModal() {
2171
3597
  if (!isOpen) return;
2172
3598
  isOpen = false;
3599
+ if (settingsOpen) setSettingsOpen(false);
2173
3600
  clearTimeout(searchTimer);
2174
3601
  abortAgent("closed");
2175
3602
  backdrop.classList.remove("sa-open");
2176
3603
  trigger.style.display = "";
2177
3604
  trigger.focus();
3605
+ updateKeepalive();
2178
3606
  }
2179
3607
 
2180
3608
  function applyTriggerOffsets() {
@@ -2288,7 +3716,38 @@ const EddieLib = {};
2288
3716
  openModal();
2289
3717
  });
2290
3718
 
3719
+ // A page going away tells the service worker to drop its ports and stop
3720
+ // any answer it is still generating for us.
3721
+ window.addEventListener("pagehide", () => {
3722
+ if (agentRun && !agentRun.done && agent) agent.postRaw({ type: "abort", requestId: agentRun.requestId });
3723
+ for (const t of [search, agent]) {
3724
+ if (!t || t.kind !== "sw") continue;
3725
+ try {
3726
+ t.postRaw({ type: "disconnect" });
3727
+ } catch (_) {
3728
+ // port already gone
3729
+ }
3730
+ }
3731
+ });
3732
+
2291
3733
  // -- Mount --
2292
3734
  document.body.appendChild(host);
3735
+ host.dataset.persist = config.persist;
3736
+ host.dataset.warm = config.warm;
3737
+ host.dataset.boot = scriptEl.getAttribute("data-boot") || "";
3738
+ window.eddie = Object.assign(window.eddie || {}, { open: openModal, close: closeModal });
3739
+ // Handed over from eddie-boot.js: drop its trigger and honour a click or
3740
+ // shortcut that arrived while this script was loading.
3741
+ const boot = window.__eddieBoot;
3742
+ if (boot && typeof boot.dispose === "function") {
3743
+ const wantOpen = !!boot.open;
3744
+ boot.dispose();
3745
+ if (wantOpen) openModal();
3746
+ }
3747
+ afterLoad(() =>
3748
+ onIdle(() => {
3749
+ warmUp().catch((err) => console.warn("eddie warm-up failed", err));
3750
+ })
3751
+ );
2293
3752
  })();
2294
3753
  })();