eddie-jekyll 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,95 +650,6 @@ 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() + "…";
561
- }
562
-
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");
568
- }
569
-
570
- function sourcesPrompt(items, question, maxChars) {
571
- return `Sources:\n\n${formatEvidence(items, maxChars)}\n\nQuestion: ${question}`;
572
- }
573
-
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;
598
- }
599
- }
600
- merged.push(line);
601
- }
602
- text = merged.join("\n");
603
-
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 };
613
- }
614
- text = withoutFallback;
615
-
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);
628
- }
629
- }
630
- }
631
- return out;
632
- });
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 === "" };
640
- }
641
-
642
-
643
653
  // FAQ card gate: prefer the WASM's fused `confident` flag (qa_lookup v0.4.1+);
644
654
  // older indexes only carry a dense score, so fall back to a plain cutoff.
645
655
  function faqPasses(hit, qaMode) {
@@ -671,31 +681,614 @@ const EddieLib = {};
671
681
  qaEvidence,
672
682
  NOHIT,
673
683
  AGENT_MODEL_SIZES,
674
- PLAN_SCHEMA,
675
- planPrompt,
676
- answerPrompt,
677
- sourcesPrompt,
678
684
  baseModelId,
679
685
  agentModelBytes,
680
686
  selectAgentModel,
681
687
  isMobileDevice,
682
- stripThink,
683
688
  visibleStreamText,
684
- parsePlan,
685
689
  mergeEvidence,
686
- truncateText,
687
- formatEvidence,
688
- postProcessAnswer,
689
690
  };
690
691
  });
691
692
 
692
693
  // SPDX-License-Identifier: GPL-3.0-only
693
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);
720
+ }
721
+ })(function () {
722
+ "use strict";
723
+
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);
754
+ }
755
+ }
756
+ return set.size > 0;
757
+ }
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
+ }
766
+
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
+ };
855
+ }
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
+ }
874
+
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
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
1008
+ }
1009
+ this.port = null;
1010
+ }
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)));
1071
+ });
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
+ }
1186
+ }
1187
+
1188
+ return {
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,
1209
+ };
1210
+ });
1211
+
1212
+ // SPDX-License-Identifier: GPL-3.0-only
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
+
694
1283
  // Eddie search widget
695
1284
  //
696
1285
  // Self-contained vanilla JS widget in a closed Shadow DOM. Talks to the
697
- // search worker (eddie-worker.js) for retrieval and, on demand, to the agent
698
- // 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.
699
1292
  //
700
1293
  // widget/build.sh concatenates widget/src/lib/*.js ahead of this file inside
701
1294
  // one IIFE, so the pure helpers are available as `EddieLib` without leaking a
@@ -706,9 +1299,23 @@ const EddieLib = {};
706
1299
  (function () {
707
1300
  const scriptEl = document.currentScript;
708
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;
709
1305
  const lib = EddieLib;
710
1306
 
711
- 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
+ );
712
1319
  const scriptHref = new URL(scriptEl.src, location.href).href;
713
1320
  const baseUrl = lib.baseUrlOf(scriptHref);
714
1321
  const indexUrl = config.indexUrl
@@ -718,6 +1325,8 @@ const EddieLib = {};
718
1325
  const siteName = config.qaSubject || location.hostname;
719
1326
  const reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
720
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());
721
1330
 
722
1331
  const HEART_SPRITES = [
723
1332
  [".11111.", "1222221", "1222221", ".12221."], // solid
@@ -728,27 +1337,59 @@ const EddieLib = {};
728
1337
  const HEART_PALETTE = { 1: "#f2c94c", 2: "#e0b63f", 3: "#f8dda1", 4: "#b78e28" };
729
1338
  const HIDDEN_SECTIONS = /^(summary|summary lane|semantic segment)$/i;
730
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
+ }
731
1358
 
732
1359
  // -- State --
733
- 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
734
1363
  let workerState = "idle"; // idle | loading | index_ready | awaiting_consent | ready | error | dead
735
1364
  let searchable = false;
1365
+ let initSent = false; // an init went to the current search transport
736
1366
  let requestSeq = 0;
737
- const pending = new Map(); // requestId -> { resolve, reject }
738
1367
  let activeSearchId = 0;
739
1368
  let isOpen = false;
740
1369
  let selectedIndex = -1;
741
1370
  let currentResults = [];
742
1371
  let lastHeartIndex = -1;
743
1372
  let consentDeclined = false;
1373
+ let pendingConsentLane = null; // lane id of the consent card being shown
744
1374
  let lastDegradedNotice = null;
745
1375
  let lastAnnouncedQuarter = -1;
746
1376
  let manifestInfo = null;
747
1377
  let searchTimer = null;
748
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;
749
1389
 
750
1390
  let agentInfo = null; // null (unknown) | false | { maxBufferSize, hasF16 }
751
- let agentWorker = null;
1391
+ let agent = null; // agent transport, null until the first Ask
1392
+ let agentCreating = null;
752
1393
  let agentModel = null; // { id, base, sizeBytes }
753
1394
  let agentLoaded = false;
754
1395
  let agentLoading = null; // Promise while a load is in flight
@@ -936,7 +1577,25 @@ const EddieLib = {};
936
1577
  line-height: 1;
937
1578
  transition: border-color 0.1s;
938
1579
  }
939
- .sa-close:hover { border-color: var(--sa-text-muted); }
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; }
940
1599
 
941
1600
  .sa-heart { flex-shrink: 0; width: 14px; height: 8px; image-rendering: pixelated; image-rendering: crisp-edges; opacity: 0.95; display: block; }
942
1601
 
@@ -1174,6 +1833,14 @@ const EddieLib = {};
1174
1833
  askBtn.hidden = true;
1175
1834
  header.appendChild(askBtn);
1176
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
+
1177
1844
  const closeBtn = button("sa-close", "esc", closeModal, { "aria-label": "Close (Esc)" });
1178
1845
  header.appendChild(closeBtn);
1179
1846
 
@@ -1219,6 +1886,22 @@ const EddieLib = {};
1219
1886
  consentCard.appendChild(consentActions);
1220
1887
  modal.appendChild(consentCard);
1221
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
+
1222
1905
  // Error area
1223
1906
  const errorEl = el("div", "sa-error");
1224
1907
  errorEl.setAttribute("role", "alert");
@@ -1353,7 +2036,9 @@ const EddieLib = {};
1353
2036
  modal.addEventListener("keydown", (e) => {
1354
2037
  if (e.key === "Escape") {
1355
2038
  e.preventDefault();
1356
- closeModal();
2039
+ // Esc backs out of the settings panel first, then closes the modal.
2040
+ if (settingsOpen) setSettingsOpen(false);
2041
+ else closeModal();
1357
2042
  return;
1358
2043
  }
1359
2044
  if (e.key !== "Tab") return;
@@ -1383,50 +2068,315 @@ const EddieLib = {};
1383
2068
  return true;
1384
2069
  }
1385
2070
 
1386
- // -- Search worker --
1387
- function ensureWorker() {
1388
- if (worker) {
1389
- if (workerState === "error") retryInit();
1390
- return;
1391
- }
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;
1392
2110
  try {
1393
- 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 };
1394
2134
  } catch (err) {
1395
- showInitError("Couldn't start the search worker: " + (err && err.message ? err.message : err), false);
1396
- 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
+ }
1397
2214
  }
1398
- worker.onmessage = (e) => onWorkerMessage(e.data || {});
1399
- 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) => {
1400
2263
  // An uncaught error in the worker script (a 404, a syntax error, an
1401
- // exception outside a handler): start a fresh worker on Retry.
1402
- 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.
1403
2266
  setWorkerState("dead");
1404
2267
  searchable = false;
1405
2268
  showStatus(false);
1406
- showInitError(message, true);
1407
- 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 : [],
1408
2348
  };
1409
- postInit(false);
2349
+ if (settingsOpen) currentChoices().then(renderSettings);
1410
2350
  }
1411
2351
 
1412
2352
  function postInit(consent) {
2353
+ if (!search) return;
2354
+ initSent = true;
1413
2355
  setWorkerState("loading");
1414
2356
  hide(errorEl);
1415
- worker.postMessage({
2357
+ search.postRaw({
1416
2358
  type: "init",
1417
2359
  indexUrl,
1418
2360
  baseUrl,
1419
2361
  version,
1420
2362
  denseRuntime: config.denseRuntime,
2363
+ laneId: config.laneId || null,
1421
2364
  consent: !!consent,
2365
+ consentLane: consent ? pendingConsentLane : undefined,
1422
2366
  });
2367
+ updateKeepalive();
1423
2368
  }
1424
2369
 
1425
2370
  function retryInit() {
1426
2371
  hide(errorEl);
1427
- if (!worker || workerState === "dead") {
1428
- if (worker) worker.terminate();
1429
- 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;
1430
2380
  searchable = false;
1431
2381
  setWorkerState("idle");
1432
2382
  ensureWorker();
@@ -1435,80 +2385,112 @@ const EddieLib = {};
1435
2385
  postInit(false);
1436
2386
  }
1437
2387
 
1438
- function callWorker(type, payload) {
2388
+ /** Resolves once the index is loaded in the current transport. */
2389
+ function whenIndexLoaded() {
2390
+ if (searchable) return Promise.resolve();
1439
2391
  return new Promise((resolve, reject) => {
1440
- if (!worker) {
1441
- reject(new Error("search worker not running"));
1442
- return;
1443
- }
1444
- const requestId = ++requestSeq;
1445
- pending.set(requestId, { resolve, reject });
1446
- worker.postMessage(Object.assign({ type, requestId }, payload || {}));
2392
+ initWaiters.push({ resolve, reject });
1447
2393
  });
1448
2394
  }
1449
2395
 
1450
- function failAllPending(err) {
1451
- pending.forEach((p) => p.reject(err));
1452
- pending.clear();
2396
+ function resolveInitWaiters() {
2397
+ const waiters = initWaiters;
2398
+ initWaiters = [];
2399
+ waiters.forEach((w) => w.resolve());
1453
2400
  }
1454
2401
 
1455
- function onWorkerMessage(msg) {
1456
- if (msg.requestId != null && pending.has(msg.requestId)) {
1457
- const p = pending.get(msg.requestId);
1458
- pending.delete(msg.requestId);
1459
- if (msg.type === "error") {
1460
- const err = new Error(msg.message || "request failed");
1461
- err.fatal = !!msg.fatal;
1462
- p.reject(err);
1463
- if (msg.fatal) onFatal(msg.message);
1464
- } else {
1465
- 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;
1466
2427
  }
1467
- return;
1468
- }
1469
- switch (msg.type) {
1470
- case "status":
1471
- handleStatus(msg);
1472
- break;
1473
- case "ready":
1474
- handleReady(msg);
1475
- break;
1476
- case "error":
1477
- if (msg.fatal) onFatal(msg.message);
1478
- else showInitError(msg.message || "Search failed", false);
1479
- break;
1480
- default:
1481
- 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 });
1482
2432
  }
1483
2433
  }
1484
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
+
1485
2445
  function onFatal(message) {
1486
2446
  setWorkerState("dead");
1487
2447
  searchable = false;
1488
2448
  showStatus(false);
1489
2449
  showInitError(message || "The search engine crashed.", true);
2450
+ failInitWaiters(new Error(message || "search engine crashed"));
1490
2451
  }
1491
2452
 
1492
2453
  function handleStatus(msg) {
1493
2454
  switch (msg.state) {
1494
2455
  case "loading_wasm":
1495
2456
  setWorkerState("loading");
1496
- setStatus("Loading search engine…", null);
2457
+ setStatus(msg.variant === "dense" ? "Loading the dense search engine…" : "Loading search engine…", null);
1497
2458
  announce("Loading search engine");
1498
2459
  break;
1499
2460
  case "loading_index":
1500
2461
  setStatus("Loading index…", msg.progress);
1501
2462
  break;
2463
+ case "loading_sidecar":
2464
+ setStatus("Loading index vectors…", msg.progress);
2465
+ break;
1502
2466
  case "index_ready":
1503
2467
  manifestInfo = msg.manifest || null;
2468
+ rememberLanes(msg);
1504
2469
  setWorkerState("index_ready");
1505
2470
  searchable = true;
1506
- setStatus("Loading search model…", null);
2471
+ if (initSent) setStatus("Loading search model…", null);
2472
+ resolveInitWaiters();
1507
2473
  rerunQuery();
1508
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;
1509
2488
  case "consent_required":
1510
2489
  setWorkerState("awaiting_consent");
2490
+ rememberLanes(msg);
1511
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;
1512
2494
  if (consentDeclined) break;
1513
2495
  showConsent(msg);
1514
2496
  break;
@@ -1532,6 +2514,7 @@ const EddieLib = {};
1532
2514
  setWorkerState(msg.fatal ? "dead" : "error");
1533
2515
  showStatus(false);
1534
2516
  showInitError(msg.message || "Failed to initialise search", !msg.unsupported);
2517
+ failInitWaiters(new Error(msg.message || "search failed to initialise"));
1535
2518
  break;
1536
2519
  default:
1537
2520
  break;
@@ -1542,15 +2525,130 @@ const EddieLib = {};
1542
2525
  setWorkerState("ready");
1543
2526
  searchable = true;
1544
2527
  manifestInfo = msg.manifest || manifestInfo;
2528
+ rememberLanes(msg);
1545
2529
  const arms = msg.arms || {};
1546
2530
  host.dataset.arms = Object.keys(arms).filter((k) => arms[k]).join(",");
1547
2531
  host.dataset.lane = msg.lane || "";
1548
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");
1549
2537
  showStatus(false);
1550
2538
  hide(consentCard);
1551
2539
  lastDegradedNotice = lib.degradedNotice(msg.arms, msg.degraded);
1552
2540
  announce(lastDegradedNotice ? "Search ready. " + lastDegradedNotice : "Search ready");
2541
+ resolveInitWaiters();
1553
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
+ }
1554
2652
  }
1555
2653
 
1556
2654
  function rerunQuery() {
@@ -1566,20 +2664,267 @@ const EddieLib = {};
1566
2664
  consentText.textContent = lib.consentCopy({
1567
2665
  sizeBytes: msg.sizeBytes,
1568
2666
  model: short,
2667
+ origin: msg.origin || (lane.origin || "huggingface"),
2668
+ sidecarBytes: msg.sidecarBytes || 0,
1569
2669
  saveData: saveData || !!msg.saveData,
1570
2670
  consentText: config.consentText,
1571
2671
  });
1572
2672
  consentAccept.textContent = msg.sizeBytes == null ? "Download" : `Download ${lib.formatBytes(msg.sizeBytes)}`;
2673
+ if (settingsOpen) setSettingsOpen(false);
1573
2674
  show(consentCard);
1574
2675
  announce("Semantic search needs a model download. " + consentText.textContent);
1575
2676
  }
1576
2677
 
1577
- function acceptConsent() {
2678
+ async function acceptConsent() {
1578
2679
  hide(consentCard);
1579
- if (!worker) return;
1580
- setWorkerState("loading");
1581
- 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);
1582
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.");
1583
2928
  }
1584
2929
 
1585
2930
  function declineConsent() {
@@ -1607,32 +2952,27 @@ const EddieLib = {};
1607
2952
  }
1608
2953
 
1609
2954
  async function doSearch(query, explicit) {
1610
- if (!searchable || !worker) return;
2955
+ if (!searchable || !search) return;
1611
2956
  // A different query cancels a running answer; re-searching the question
1612
2957
  // being answered (a late debounce, a re-run after init) does not.
1613
2958
  if (agentRun && !agentRun.done && query !== agentRun.question) abortAgent("new search");
1614
2959
  lastSearchedQuery = query;
1615
- const requestId = ++requestSeq;
1616
- activeSearchId = requestId;
1617
- pending.set(requestId, {
1618
- resolve: (msg) => {
1619
- if (requestId !== activeSearchId) return;
1620
- renderSearchResult(msg, query);
1621
- },
1622
- reject: (err) => {
1623
- if (requestId !== activeSearchId) return;
1624
- showSearchError(err.message || "Search failed");
1625
- },
1626
- });
1627
- worker.postMessage({
1628
- type: "search",
1629
- requestId,
1630
- query,
1631
- topK: config.topK,
1632
- mode: "hybrid",
1633
- qa: wantQa(query) ? Math.max(1, Math.min(config.answerTopK, 3)) : 0,
1634
- explicit: !!explicit,
1635
- });
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
+ }
1636
2976
  }
1637
2977
 
1638
2978
  function renderSearchResult(msg, query) {
@@ -1789,21 +3129,13 @@ const EddieLib = {};
1789
3129
  // -- Agent --
1790
3130
  async function detectAgent() {
1791
3131
  if (agentInfo !== null) return agentInfo;
1792
- agentInfo = false;
1793
- if (config.agentMode === "off" || !navigator.gpu || typeof navigator.gpu.requestAdapter !== "function") {
3132
+ if (config.agentMode === "off") {
3133
+ agentInfo = false;
1794
3134
  return agentInfo;
1795
3135
  }
1796
- try {
1797
- const adapter = await navigator.gpu.requestAdapter();
1798
- if (adapter) {
1799
- agentInfo = {
1800
- maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
1801
- hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
1802
- };
1803
- }
1804
- } catch (err) {
1805
- console.warn("eddie: WebGPU adapter probe failed", err);
1806
- }
3136
+ const gpu = await probePageGpu();
3137
+ if (agentInfo !== null) return agentInfo;
3138
+ agentInfo = gpu ? { maxBufferSize: gpu.maxBufferSize, hasF16: gpu.hasF16 } : false;
1807
3139
  if (agentInfo) {
1808
3140
  agentModel = lib.selectAgentModel({
1809
3141
  mode: config.agentModel,
@@ -1954,12 +3286,41 @@ const EddieLib = {};
1954
3286
  });
1955
3287
  }
1956
3288
 
1957
- function ensureAgentWorker() {
1958
- if (agentWorker) return;
1959
- agentWorker = new Worker(lib.assetUrl(baseUrl, "eddie-agent-worker.js", version), { type: "module" });
1960
- agentWorker.onmessage = (e) => onAgentMessage(e.data || {});
1961
- agentWorker.onerror = (e) => {
1962
- 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";
1963
3324
  if (agentPendingLoad) {
1964
3325
  agentPendingLoad.reject(new Error(message));
1965
3326
  agentPendingLoad = null;
@@ -1968,30 +3329,67 @@ const EddieLib = {};
1968
3329
  agentRun.done = true;
1969
3330
  showAnswerError(message, agentRun.question);
1970
3331
  }
1971
- };
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
+ });
1972
3346
  }
1973
3347
 
1974
3348
  function loadAgent(run) {
1975
3349
  if (agentLoaded) return Promise.resolve();
1976
3350
  if (agentLoading) return agentLoading;
1977
- ensureAgentWorker();
1978
- agentLoading = new Promise((resolve, reject) => {
1979
- agentPendingLoad = { resolve, reject };
1980
- answerProgress.textContent = `Loading ${agentModel.base}…`;
1981
- announce(`Loading the answer model, ${agentModel.base}`);
1982
- agentWorker.postMessage({ type: "load", model: agentModel.id });
1983
- }).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(() => {
1984
3378
  agentLoading = null;
1985
3379
  });
1986
3380
  return agentLoading;
1987
3381
  }
1988
3382
 
1989
- const agentPending = new Map();
1990
- function agentCall(type, payload) {
1991
- return new Promise((resolve, reject) => {
1992
- agentPending.set(payload.requestId, { resolve, reject });
1993
- agentWorker.postMessage(Object.assign({ type }, payload));
1994
- });
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
+ }
1995
3393
  }
1996
3394
 
1997
3395
  function streamAnswer(run) {
@@ -2006,7 +3404,8 @@ const EddieLib = {};
2006
3404
  caret.setAttribute("aria-hidden", "true");
2007
3405
  run.caret = caret;
2008
3406
  answerText.appendChild(caret);
2009
- 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 });
2010
3409
  });
2011
3410
  }
2012
3411
 
@@ -2038,8 +3437,7 @@ const EddieLib = {};
2038
3437
  }
2039
3438
  break;
2040
3439
  case "plan_result":
2041
- resolveAgent(msg);
2042
- break;
3440
+ break; // settled by the transport's call()
2043
3441
  case "token":
2044
3442
  if (agentRun && msg.requestId === agentRun.requestId && !agentRun.done) {
2045
3443
  agentRun.text += msg.text;
@@ -2056,11 +3454,7 @@ const EddieLib = {};
2056
3454
  }
2057
3455
  break;
2058
3456
  case "error":
2059
- if (msg.requestId != null && agentPending.has(msg.requestId)) {
2060
- const p = agentPending.get(msg.requestId);
2061
- agentPending.delete(msg.requestId);
2062
- p.reject(new Error(msg.message || "agent request failed"));
2063
- } else if (agentPendingLoad) {
3457
+ if (agentPendingLoad && msg.requestId == null) {
2064
3458
  agentPendingLoad.reject(new Error(msg.message || "model load failed"));
2065
3459
  agentPendingLoad = null;
2066
3460
  } else if (agentRun && !agentRun.done && (msg.requestId == null || msg.requestId === agentRun.requestId)) {
@@ -2073,13 +3467,6 @@ const EddieLib = {};
2073
3467
  }
2074
3468
  }
2075
3469
 
2076
- function resolveAgent(msg) {
2077
- const p = agentPending.get(msg.requestId);
2078
- if (!p) return;
2079
- agentPending.delete(msg.requestId);
2080
- p.resolve(msg);
2081
- }
2082
-
2083
3470
  function finishAnswer(run, msg) {
2084
3471
  run.done = true;
2085
3472
  clearAnswerActions();
@@ -2112,6 +3499,7 @@ const EddieLib = {};
2112
3499
  run.usage = usage;
2113
3500
  announce(msg.nohit ? "No answer: the site doesn't cover that." : "Answer ready. " + msg.answer);
2114
3501
  if (run.resolveStream) run.resolveStream();
3502
+ updateKeepalive();
2115
3503
  console.info("eddie agent " + JSON.stringify({
2116
3504
  question: run.question,
2117
3505
  model: agentModel ? agentModel.id : null,
@@ -2122,7 +3510,10 @@ const EddieLib = {};
2122
3510
  totalMs: usage.totalMs,
2123
3511
  tps: usage.tps,
2124
3512
  sinceAskMs: Math.round(performance.now() - run.started),
3513
+ agentReused: host.dataset.agentReused === "true",
3514
+ transport: agent ? agent.kind : null,
2125
3515
  }));
3516
+ host.dataset.agentDoneMs = String(Math.round(performance.now()));
2126
3517
  }
2127
3518
 
2128
3519
  function renderAnswerText(text, citations) {
@@ -2152,10 +3543,9 @@ const EddieLib = {};
2152
3543
  console.debug("eddie agent abort " + reason + " " + run.requestId);
2153
3544
  run.aborted = true;
2154
3545
  run.done = true;
2155
- if (agentWorker) agentWorker.postMessage({ type: "abort", requestId: run.requestId });
2156
- agentPending.forEach((p) => p.reject(new Error("aborted")));
2157
- agentPending.clear();
3546
+ if (agent) agent.postRaw({ type: "abort", requestId: run.requestId });
2158
3547
  if (run.resolveStream) run.resolveStream();
3548
+ updateKeepalive();
2159
3549
  if (reason === "stopped") {
2160
3550
  paintStream(run, true);
2161
3551
  clearAnswerActions();
@@ -2187,7 +3577,9 @@ const EddieLib = {};
2187
3577
 
2188
3578
  // -- Modal open/close --
2189
3579
  function openModal() {
3580
+ if (isOpen) return;
2190
3581
  isOpen = true;
3582
+ rememberSearchUsed();
2191
3583
  rotateHeartSprite();
2192
3584
  backdrop.classList.add("sa-open");
2193
3585
  trigger.style.display = "none";
@@ -2197,17 +3589,20 @@ const EddieLib = {};
2197
3589
  hide(answerCard);
2198
3590
  ensureWorker();
2199
3591
  detectAgent();
3592
+ updateKeepalive();
2200
3593
  requestAnimationFrame(() => input.focus());
2201
3594
  }
2202
3595
 
2203
3596
  function closeModal() {
2204
3597
  if (!isOpen) return;
2205
3598
  isOpen = false;
3599
+ if (settingsOpen) setSettingsOpen(false);
2206
3600
  clearTimeout(searchTimer);
2207
3601
  abortAgent("closed");
2208
3602
  backdrop.classList.remove("sa-open");
2209
3603
  trigger.style.display = "";
2210
3604
  trigger.focus();
3605
+ updateKeepalive();
2211
3606
  }
2212
3607
 
2213
3608
  function applyTriggerOffsets() {
@@ -2321,7 +3716,38 @@ const EddieLib = {};
2321
3716
  openModal();
2322
3717
  });
2323
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
+
2324
3733
  // -- Mount --
2325
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
+ );
2326
3752
  })();
2327
3753
  })();