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.
- checksums.yaml +4 -4
- data/assets/assets.list +46 -0
- data/assets/eddie-agent-worker.js +425 -320
- data/assets/eddie-boot.js +234 -0
- data/assets/eddie-dense-esm.js +624 -0
- data/assets/eddie-dense.js +632 -0
- data/assets/{eddie.wasm → eddie-dense.wasm} +0 -0
- data/assets/eddie-lite-esm.js +578 -0
- data/assets/{eddie-wasm.js → eddie-lite.js} +100 -59
- data/assets/eddie-lite.wasm +0 -0
- data/assets/eddie-sw-agent.js +846 -0
- data/assets/eddie-sw-dense.js +1873 -0
- data/assets/eddie-sw-gpu.js +1873 -0
- data/assets/eddie-sw-lite.js +1872 -0
- data/assets/eddie-transformers-sw.js +30926 -0
- data/assets/eddie-widget.js +1884 -458
- data/assets/eddie-worker.js +1205 -652
- data/scripts/install.sh +17 -9
- metadata +16 -5
data/assets/eddie-widget.js
CHANGED
|
@@ -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
|
-
//
|
|
171
|
-
//
|
|
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 `{
|
|
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 ?
|
|
329
|
-
|
|
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(
|
|
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
|
|
382
|
-
//
|
|
383
|
-
//
|
|
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
|
|
698
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
// --
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
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)
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
1428
|
-
if (
|
|
1429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1451
|
-
|
|
1452
|
-
|
|
2396
|
+
function resolveInitWaiters() {
|
|
2397
|
+
const waiters = initWaiters;
|
|
2398
|
+
initWaiters = [];
|
|
2399
|
+
waiters.forEach((w) => w.resolve());
|
|
1453
2400
|
}
|
|
1454
2401
|
|
|
1455
|
-
function
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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 (!
|
|
1580
|
-
|
|
1581
|
-
|
|
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 || !
|
|
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
|
|
1616
|
-
activeSearchId =
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
3132
|
+
if (config.agentMode === "off") {
|
|
3133
|
+
agentInfo = false;
|
|
1794
3134
|
return agentInfo;
|
|
1795
3135
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
1990
|
-
function agentCall(type, payload) {
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
})();
|