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