eddie-jekyll 0.2.4 → 0.4.1

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.
@@ -1,61 +1,723 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
+ // Generated by widget/build.sh from widget/src/eddie-widget.js and widget/src/lib/*.js; edit those instead.
3
+ (function () {
4
+ "use strict";
5
+ const EddieLib = {};
6
+ // SPDX-License-Identifier: GPL-3.0-only
2
7
 
3
- // Eddie search widget
4
- //
5
- // Self-contained vanilla JS widget using Shadow DOM for style isolation.
6
- // Embeds a search modal that communicates with a Web Worker running
7
- // WASM-based semantic + keyword search.
8
+ // Widget configuration: parse the data-* attributes of the <script> tag.
9
+ // Pure; `get(name)` returns the attribute string or null.
10
+
11
+ (function (factory) {
12
+ const api = factory();
13
+ if (typeof module === "object" && module && module.exports) {
14
+ module.exports = api;
15
+ } else if (typeof EddieLib === "object" && EddieLib) {
16
+ Object.assign(EddieLib, api);
17
+ } else {
18
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
19
+ }
20
+ })(function () {
21
+ "use strict";
8
22
 
9
- "use strict";
23
+ const POSITIONS = new Set(["top-left", "top-right", "bottom-left", "bottom-right"]);
10
24
 
11
- (function () {
12
- const scriptEl = document.currentScript;
13
- if (!scriptEl) return;
25
+ function oneOf(raw, allowed, fallback) {
26
+ const value = (raw || "").trim().toLowerCase();
27
+ return allowed.includes(value) ? value : fallback;
28
+ }
29
+
30
+ function intAttr(raw, fallback) {
31
+ if (raw == null || raw === "") return fallback;
32
+ const value = Number.parseInt(raw, 10);
33
+ return Number.isFinite(value) && value > 0 ? value : fallback;
34
+ }
14
35
 
15
- function parseOffsetPx(attrName) {
16
- const raw = scriptEl.getAttribute(attrName);
36
+ function offsetAttr(raw) {
17
37
  if (raw == null || raw === "") return 0;
18
38
  const value = Number(raw);
19
39
  return Number.isFinite(value) ? Math.trunc(value) : 0;
20
40
  }
21
41
 
22
- function parseIntAttr(attrName, fallback) {
23
- const raw = scriptEl.getAttribute(attrName);
24
- if (raw == null || raw === "") return fallback;
25
- const value = Number.parseInt(raw, 10);
26
- return Number.isFinite(value) && value > 0 ? value : fallback;
42
+ /**
43
+ * Parse the widget configuration from a `get(attributeName)` accessor.
44
+ * Every value is validated; unknown values fall back to the documented default.
45
+ */
46
+ function parseWidgetConfig(get) {
47
+ const agentModelRaw = (get("data-agent-model") || "").trim();
48
+ return {
49
+ indexUrl: (get("data-index-url") || "").trim(),
50
+ position: (function () {
51
+ const v = (get("data-position") || "").trim().toLowerCase();
52
+ return POSITIONS.has(v) ? v : "bottom-right";
53
+ })(),
54
+ theme: oneOf(get("data-theme"), ["auto", "light", "dark"], "auto"),
55
+ offsetX: offsetAttr(get("data-offset-x")),
56
+ offsetY: offsetAttr(get("data-offset-y")),
57
+ qaMode: oneOf(get("data-qa-mode"), ["off", "auto", "always"], "auto"),
58
+ qaSubject: (get("data-qa-subject") || "").trim(),
59
+ topK: intAttr(get("data-top-k"), 8),
60
+ answerTopK: intAttr(get("data-answer-top-k"), 5),
61
+ agentMode: oneOf(get("data-agent-mode"), ["off", "auto"], "auto"),
62
+ agentModel: agentModelRaw === "" ? "auto" : agentModelRaw,
63
+ denseRuntime: oneOf(get("data-dense-runtime"), ["auto", "wasm", "webgpu"], "auto"),
64
+ consentText: (get("data-consent-text") || "").trim(),
65
+ };
66
+ }
67
+
68
+ return { parseWidgetConfig };
69
+ });
70
+
71
+ // SPDX-License-Identifier: GPL-3.0-only
72
+
73
+ // URL, version and cache-key helpers shared by the widget and the worker.
74
+
75
+ (function (factory) {
76
+ const api = factory();
77
+ if (typeof module === "object" && module && module.exports) {
78
+ module.exports = api;
79
+ } else if (typeof EddieLib === "object" && EddieLib) {
80
+ Object.assign(EddieLib, api);
81
+ } else {
82
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
83
+ }
84
+ })(function () {
85
+ "use strict";
86
+
87
+ const HF_HOST = "https://huggingface.co";
88
+
89
+ /** Directory of an absolute URL, without query or hash, with a trailing slash. */
90
+ function baseUrlOf(href) {
91
+ const u = new URL(href);
92
+ u.search = "";
93
+ u.hash = "";
94
+ const s = u.href;
95
+ return s.substring(0, s.lastIndexOf("/") + 1);
96
+ }
97
+
98
+ /** The `v` query parameter of a URL (absolute or relative), or null. */
99
+ function versionOf(href) {
100
+ if (!href) return null;
101
+ const q = href.indexOf("?");
102
+ if (q < 0) return null;
103
+ const params = new URLSearchParams(href.substring(q + 1).split("#")[0]);
104
+ const v = params.get("v");
105
+ return v ? v : null;
106
+ }
107
+
108
+ /** Append `?v=<version>` unless the URL already carries a `v` parameter. */
109
+ function withVersion(url, version) {
110
+ if (!version || versionOf(url)) return url;
111
+ const hashAt = url.indexOf("#");
112
+ const hash = hashAt >= 0 ? url.substring(hashAt) : "";
113
+ const body = hashAt >= 0 ? url.substring(0, hashAt) : url;
114
+ const sep = body.includes("?") ? (body.endsWith("?") || body.endsWith("&") ? "" : "&") : "?";
115
+ return body + sep + "v=" + encodeURIComponent(version) + hash;
116
+ }
117
+
118
+ /** Runtime asset next to the widget script, version-busted when a version is known. */
119
+ function assetUrl(baseUrl, name, version) {
120
+ const base = baseUrl ? baseUrl.replace(/\/?$/, "/") : "";
121
+ return withVersion(base + name, version);
122
+ }
123
+
124
+ /** `https://huggingface.co/<repo>/resolve/<revision>/<file>` */
125
+ function hfFileUrl(repo, revision, file) {
126
+ const rev = encodeURIComponent(revision || "main");
127
+ const path = String(file).split("/").map(encodeURIComponent).join("/");
128
+ return `${HF_HOST}/${repo}/resolve/${rev}/${path}`;
129
+ }
130
+
131
+ /** IndexedDB key for a cached model file. */
132
+ function cacheKey(repo, revision, file) {
133
+ return `${repo}@${revision || "main"}/${file}`;
134
+ }
135
+
136
+ const HF_RESOLVE_RE = /^https:\/\/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/([^/]+)\/([^?#]+)/;
137
+
138
+ /** The `cacheKey` of an `hfFileUrl`-shaped URL, or null for any other URL. */
139
+ function cacheKeyFromUrl(url) {
140
+ const m = HF_RESOLVE_RE.exec(String(url || ""));
141
+ if (!m) return null;
142
+ return cacheKey(m[1], decodeURIComponent(m[2]), m[3].split("/").map(decodeURIComponent).join("/"));
143
+ }
144
+
145
+ function isWeightsFile(file) {
146
+ return /\.(safetensors|onnx|onnx_data|bin|data|pt|gguf)$/i.test(String(file));
147
+ }
148
+
149
+ /** Per-file fetch timeout: 10 minutes for weights, 60 seconds for everything else. */
150
+ function timeoutForFile(file) {
151
+ return isWeightsFile(file) ? 600000 : 60000;
152
+ }
153
+
154
+ return {
155
+ HF_HOST,
156
+ baseUrlOf,
157
+ versionOf,
158
+ withVersion,
159
+ assetUrl,
160
+ hfFileUrl,
161
+ cacheKey,
162
+ cacheKeyFromUrl,
163
+ isWeightsFile,
164
+ timeoutForFile,
165
+ };
166
+ });
167
+
168
+ // SPDX-License-Identifier: GPL-3.0-only
169
+
170
+ // Dense-lane selection, download sizes, consent copy and degraded-arm notices.
171
+ // Pure functions over the index manifest (see src/manifest.rs).
172
+
173
+ (function (factory) {
174
+ const api = factory();
175
+ if (typeof module === "object" && module && module.exports) {
176
+ module.exports = api;
177
+ } else if (typeof EddieLib === "object" && EddieLib) {
178
+ Object.assign(EddieLib, api);
179
+ } else {
180
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
181
+ }
182
+ })(function () {
183
+ "use strict";
184
+
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
+ function formatBytes(n) {
308
+ if (n == null || !Number.isFinite(n)) return "unknown size";
309
+ if (n >= 1e9) {
310
+ const g = n / 1e9;
311
+ return (g >= 10 ? Math.round(g) : Math.round(g * 10) / 10) + " GB";
312
+ }
313
+ if (n >= 1e6) return Math.round(n / 1e6) + " MB";
314
+ if (n >= 1e3) return Math.round(n / 1e3) + " KB";
315
+ return n + " B";
316
+ }
317
+
318
+ /**
319
+ * Consent card copy for a model download. `consentText` (site override) may
320
+ * contain `{size}` and `{model}` placeholders.
321
+ */
322
+ function consentCopy(opts) {
323
+ const size = formatBytes(opts.sizeBytes);
324
+ const model = opts.model || "the search model";
325
+ if (opts.consentText) {
326
+ return opts.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, model);
327
+ }
328
+ const sized = opts.sizeBytes == null ? "a one-time download (size unknown)" : `a one-time ${size} download`;
329
+ let text = `Semantic search runs a language model in your browser. That needs ${sized} of ${model}, kept in your browser's cache for next time.`;
330
+ if (opts.saveData) {
331
+ text = "Data saver is on. " + text;
332
+ }
333
+ return text;
334
+ }
335
+
336
+ /**
337
+ * Drop degraded notes that describe the index design rather than a failure
338
+ * (an index without a dense lane or sparse arm is not "degraded").
339
+ */
340
+ function filterDesignDegraded(degraded) {
341
+ return (degraded || []).filter((d) => !/index has no (dense lane|sparse arm)/.test(d));
342
+ }
343
+
344
+ /** Human notice when a semantic arm is missing, or null when nothing to say. */
345
+ function degradedNotice(arms, degraded) {
346
+ const named = filterDesignDegraded(degraded).filter((d) => /^(dense|sparse)\b/.test(d));
347
+ if (named.length === 0) return null;
348
+ const a = arms || {};
349
+ if (!a.dense && !a.sparse) {
350
+ return "Keyword-only results: the semantic model isn't available.";
351
+ }
352
+ if (!a.dense) {
353
+ return "Keyword and sparse results only: the dense model isn't available.";
354
+ }
355
+ return "Results without the learned-sparse arm.";
356
+ }
357
+
358
+ 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
+ formatBytes,
373
+ consentCopy,
374
+ filterDesignDegraded,
375
+ degradedNotice,
376
+ };
377
+ });
378
+
379
+ // SPDX-License-Identifier: GPL-3.0-only
380
+
381
+ // Agent helpers shared by the widget and the agent worker: model selection,
382
+ // prompts, plan parsing, evidence assembly and answer post-processing.
383
+ // Pure functions; no WebLLM, no DOM.
384
+
385
+ (function (factory) {
386
+ const api = factory();
387
+ if (typeof module === "object" && module && module.exports) {
388
+ module.exports = api;
389
+ } else if (typeof EddieLib === "object" && EddieLib) {
390
+ Object.assign(EddieLib, api);
391
+ } else {
392
+ globalThis.EddieLib = Object.assign(globalThis.EddieLib || {}, api);
393
+ }
394
+ })(function () {
395
+ "use strict";
396
+
397
+ 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
+
400
+ const AGENT_MODEL_SIZES = {
401
+ "Qwen3.5-0.8B": 0.4e9,
402
+ "Qwen3.5-2B": 1.2e9,
403
+ "Qwen3.5-4B": 2.3e9,
404
+ };
405
+ const TWO_GIB = 2 * 1024 * 1024 * 1024;
406
+
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
+ /** Strip the WebLLM variant suffix to get the family name shown to visitors. */
424
+ function baseModelId(id) {
425
+ return String(id).replace(/-q\d+f(16|32)_\d+-MLC$/i, "");
426
+ }
427
+
428
+ function agentModelBytes(id) {
429
+ const base = baseModelId(id);
430
+ return Object.prototype.hasOwnProperty.call(AGENT_MODEL_SIZES, base) ? AGENT_MODEL_SIZES[base] : null;
431
+ }
432
+
433
+ /**
434
+ * Choose the WebLLM model id.
435
+ * opts: { mode: "auto"|"quality"|<id>, maxBufferSize, isMobile, hasF16 }
436
+ */
437
+ function selectAgentModel(opts) {
438
+ const o = opts || {};
439
+ const mode = (o.mode || "auto").trim();
440
+ const suffix = o.hasF16 ? "-q4f16_1-MLC" : "-q4f32_1-MLC";
441
+ let base;
442
+ if (mode === "auto") {
443
+ const big = Number(o.maxBufferSize) >= TWO_GIB && !o.isMobile;
444
+ base = big ? "Qwen3.5-2B" : "Qwen3.5-0.8B";
445
+ } else if (mode === "quality") {
446
+ base = "Qwen3.5-2B";
447
+ } else {
448
+ return { id: mode, base: baseModelId(mode), sizeBytes: agentModelBytes(mode), explicit: true };
449
+ }
450
+ const id = base + suffix;
451
+ return { id, base, sizeBytes: AGENT_MODEL_SIZES[base], explicit: false };
452
+ }
453
+
454
+ function isMobileDevice(nav) {
455
+ const n = nav || {};
456
+ if (n.userAgentData && typeof n.userAgentData.mobile === "boolean") {
457
+ return n.userAgentData.mobile;
458
+ }
459
+ return /Mobi|Android|iPhone|iPad|iPod|Windows Phone/i.test(n.userAgent || "");
460
+ }
461
+
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
+ /**
471
+ * Display text for a partial stream: complete think blocks removed, and
472
+ * anything after an unclosed <think> hidden until it closes.
473
+ */
474
+ function visibleStreamText(partial) {
475
+ if (!partial) return "";
476
+ let out = String(partial).replace(/<think>[\s\S]*?<\/think>/g, "");
477
+ const open = out.indexOf("<think>");
478
+ if (open >= 0) out = out.substring(0, open);
479
+ return out.replace(/^\s+/, "");
480
+ }
481
+
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
+ }
27
492
  }
28
493
 
29
- function normalizeQaMode(raw) {
30
- const value = (raw || "").toLowerCase();
31
- if (value === "off" || value === "always" || value === "auto") {
32
- return value;
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
+ function urlKey(url) {
527
+ return String(url || "").replace(/#.*$/, "").replace(/\/+$/, "").toLowerCase();
528
+ }
529
+
530
+ /**
531
+ * Round-robin merge of several result lists, deduplicated by URL, at most
532
+ * `max` items. Each result keeps its own fields.
533
+ */
534
+ function mergeEvidence(lists, max) {
535
+ const limit = max == null ? 6 : max;
536
+ const seen = new Set();
537
+ const out = [];
538
+ const arrays = (lists || []).map((l) => (Array.isArray(l) ? l : []));
539
+ const longest = arrays.reduce((n, l) => Math.max(n, l.length), 0);
540
+ for (let i = 0; i < longest && out.length < limit; i++) {
541
+ for (const list of arrays) {
542
+ if (out.length >= limit) break;
543
+ const r = list[i];
544
+ if (!r || !r.url) continue;
545
+ const key = urlKey(r.url);
546
+ if (seen.has(key)) continue;
547
+ seen.add(key);
548
+ out.push(r);
549
+ }
33
550
  }
34
- return "auto";
551
+ return out;
552
+ }
553
+
554
+ /** Cut to `max` characters at a word boundary, with an ellipsis. */
555
+ function truncateText(text, max) {
556
+ const limit = max == null ? 700 : max;
557
+ const s = String(text || "").replace(/\s+/g, " ").trim();
558
+ if (s.length <= limit) return s;
559
+ const cut = s.lastIndexOf(" ", limit - 1);
560
+ return s.substring(0, cut > limit * 0.6 ? cut : limit - 1).trim() + "…";
561
+ }
562
+
563
+ /** `[n] title (url)\ntext` blocks joined by blank lines. */
564
+ function formatEvidence(items, maxChars) {
565
+ return (items || [])
566
+ .map((e, i) => `[${i + 1}] ${e.title || e.url} (${e.url})\n${truncateText(e.text || e.snippet || "", maxChars)}`)
567
+ .join("\n\n");
568
+ }
569
+
570
+ function sourcesPrompt(items, question, maxChars) {
571
+ return `Sources:\n\n${formatEvidence(items, maxChars)}\n\nQuestion: ${question}`;
572
+ }
573
+
574
+ /**
575
+ * Post-process a raw model answer against the evidence list.
576
+ * Returns { answer, citations: [{n, url, title}], nohit }.
577
+ */
578
+ function postProcessAnswer(raw, evidence) {
579
+ const ev = Array.isArray(evidence) ? evidence : [];
580
+ let text = stripThink(raw);
581
+ // **[1]** -> [1]
582
+ text = text.replace(/\*\*\s*((?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+)\*\*/g, "$1");
583
+ // Lines that are only citation markers belong to the previous line.
584
+ const lines = text.split(/\r?\n/);
585
+ const merged = [];
586
+ for (const line of lines) {
587
+ const t = line.trim();
588
+ if (t && /^(?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+$/.test(t)) {
589
+ let j = merged.length - 1;
590
+ while (j >= 0 && merged[j].trim() === "") j--;
591
+ if (j >= 0) {
592
+ // Append only markers the previous line does not already carry.
593
+ const prev = merged[j];
594
+ const fresh = (t.match(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g) || []).filter((m) => !prev.includes(m.replace(/\s+/g, "")));
595
+ merged.length = j + 1;
596
+ if (fresh.length) merged[j] = prev.replace(/\s+$/, "") + " " + fresh.join("");
597
+ continue;
598
+ }
599
+ }
600
+ merged.push(line);
601
+ }
602
+ text = merged.join("\n");
603
+
604
+ // Drop the fallback sentence when anything else remains.
605
+ const withoutFallback = text.replace(NOHIT_RE, "").replace(/[ \t]+\n/g, "\n").trim();
606
+ const residue = withoutFallback
607
+ .replace(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g, "")
608
+ .replace(/^\s*(?:yes|no)\b/i, "")
609
+ .replace(/[\s.,;:!?'"*-]+/g, "");
610
+ const onlyFallback = residue === "";
611
+ if (onlyFallback) {
612
+ return { answer: NOHIT, citations: [], nohit: true };
613
+ }
614
+ text = withoutFallback;
615
+
616
+ // Map [n] citations to evidence; drop out-of-range ones.
617
+ const cited = [];
618
+ const seen = new Set();
619
+ text = text.replace(/\[\s*(\d+(?:\s*,\s*\d+)*)\s*\]/g, (_, body) => {
620
+ const nums = body.split(",").map((x) => Number(x.trim()));
621
+ let out = "";
622
+ for (const n of nums) {
623
+ if (n >= 1 && n <= ev.length) {
624
+ out += `[${n}]`;
625
+ if (!seen.has(n)) {
626
+ seen.add(n);
627
+ cited.push(n);
628
+ }
629
+ }
630
+ }
631
+ return out;
632
+ });
633
+ // Collapse duplicate adjacent markers ("[1][1]") and tidy whitespace.
634
+ text = text.replace(/(\[\d+\])(?:\s*\1)+/g, "$1");
635
+ text = text.replace(/[ \t]{2,}/g, " ").replace(/ +([.,;:!?])/g, "$1").replace(/\n{3,}/g, "\n\n").trim();
636
+ const citations = cited
637
+ .sort((a, b) => a - b)
638
+ .map((n) => ({ n, url: ev[n - 1].url, title: ev[n - 1].title || ev[n - 1].url }));
639
+ return { answer: text, citations, nohit: text === "" };
640
+ }
641
+
642
+
643
+ // FAQ card gate: prefer the WASM's fused `confident` flag (qa_lookup v0.4.1+);
644
+ // older indexes only carry a dense score, so fall back to a plain cutoff.
645
+ function faqPasses(hit, qaMode) {
646
+ if (!hit || typeof hit !== "object") return false;
647
+ if (qaMode === "off") return false;
648
+ if (qaMode === "always") return true;
649
+ if (typeof hit.confident === "boolean") return hit.confident;
650
+ return typeof hit.score === "number" && hit.score >= 0.5;
35
651
  }
36
652
 
37
- function normalizePosition(raw) {
38
- const value = (raw || "").toLowerCase();
39
- const allowed = new Set([
40
- "top-left",
41
- "top-right",
42
- "bottom-left",
43
- "bottom-right",
44
- ]);
45
- return allowed.has(value) ? value : "bottom-right";
653
+ // Turn confident QA hits into agent evidence items ("Q: … A: …") so the
654
+ // answer model sees the FAQ lane, not only chunk text.
655
+ function qaEvidence(hits, max) {
656
+ const limit = max == null ? 2 : max;
657
+ const out = [];
658
+ for (const h of Array.isArray(hits) ? hits : []) {
659
+ if (out.length >= limit) break;
660
+ if (!faqPasses(h, "auto")) continue;
661
+ const q = String(h.question || "").trim();
662
+ const a = String(h.answer || "").trim();
663
+ if (!q || !a) continue;
664
+ out.push({ title: "FAQ: " + q, url: h.source_url || "", text: "Q: " + q + "\nA: " + a, faq: true });
665
+ }
666
+ return out;
46
667
  }
47
668
 
48
- const config = {
49
- indexUrl: scriptEl.getAttribute("data-index-url") || "/eddie/index.ed",
50
- position: normalizePosition(scriptEl.getAttribute("data-position")),
51
- theme: scriptEl.getAttribute("data-theme") || "auto",
52
- offsetY: parseOffsetPx("data-offset-y"),
53
- offsetX: parseOffsetPx("data-offset-x"),
54
- qaMode: normalizeQaMode(scriptEl.getAttribute("data-qa-mode")),
55
- qaSubject: (scriptEl.getAttribute("data-qa-subject") || "").toLowerCase(),
56
- resultTopK: parseIntAttr("data-top-k", 8),
57
- answerTopK: parseIntAttr("data-answer-top-k", 5),
669
+ return {
670
+ faqPasses,
671
+ qaEvidence,
672
+ NOHIT,
673
+ AGENT_MODEL_SIZES,
674
+ PLAN_SCHEMA,
675
+ planPrompt,
676
+ answerPrompt,
677
+ sourcesPrompt,
678
+ baseModelId,
679
+ agentModelBytes,
680
+ selectAgentModel,
681
+ isMobileDevice,
682
+ stripThink,
683
+ visibleStreamText,
684
+ parsePlan,
685
+ mergeEvidence,
686
+ truncateText,
687
+ formatEvidence,
688
+ postProcessAnswer,
58
689
  };
690
+ });
691
+
692
+ // SPDX-License-Identifier: GPL-3.0-only
693
+
694
+ // Eddie search widget
695
+ //
696
+ // Self-contained vanilla JS widget in a closed Shadow DOM. Talks to the
697
+ // search worker (eddie-worker.js) for retrieval and, on demand, to the agent
698
+ // worker (eddie-agent-worker.js) for a streamed, cited answer.
699
+ //
700
+ // widget/build.sh concatenates widget/src/lib/*.js ahead of this file inside
701
+ // one IIFE, so the pure helpers are available as `EddieLib` without leaking a
702
+ // global.
703
+
704
+ "use strict";
705
+
706
+ (function () {
707
+ const scriptEl = document.currentScript;
708
+ if (!scriptEl) return;
709
+ const lib = EddieLib;
710
+
711
+ const config = lib.parseWidgetConfig((name) => scriptEl.getAttribute(name));
712
+ const scriptHref = new URL(scriptEl.src, location.href).href;
713
+ const baseUrl = lib.baseUrlOf(scriptHref);
714
+ const indexUrl = config.indexUrl
715
+ ? new URL(config.indexUrl, location.href).href
716
+ : new URL("index.ed", baseUrl).href;
717
+ const version = lib.versionOf(scriptHref) || lib.versionOf(indexUrl);
718
+ const siteName = config.qaSubject || location.hostname;
719
+ const reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
720
+ const saveData = !!(navigator.connection && navigator.connection.saveData);
59
721
 
60
722
  const HEART_SPRITES = [
61
723
  [".11111.", "1222221", "1222221", ".12221."], // solid
@@ -63,40 +725,63 @@
63
725
  [".13331.", "1344431", "1244421", ".12221."], // beveled
64
726
  [".13131.", "1344431", "12.4.21", ".12221."], // gear-ish
65
727
  ];
66
-
67
- const HEART_PALETTE = {
68
- "1": "#f2c94c",
69
- "2": "#e0b63f",
70
- "3": "#f8dda1",
71
- "4": "#b78e28",
72
- };
73
-
74
- // Resolve asset URLs relative to this script's location
75
- const scriptSrc = new URL(scriptEl.src, location.href);
76
- const baseUrl = scriptSrc.href.substring(0, scriptSrc.href.lastIndexOf("/") + 1);
77
-
78
- function resolveAsset(name) {
79
- return baseUrl + name;
80
- }
728
+ const HEART_PALETTE = { 1: "#f2c94c", 2: "#e0b63f", 3: "#f8dda1", 4: "#b78e28" };
729
+ const HIDDEN_SECTIONS = /^(summary|summary lane|semantic segment)$/i;
730
+ const AGENT_CONSENT_KEY = "eddie.agent.consent";
81
731
 
82
732
  // -- State --
83
733
  let worker = null;
84
- let searchRequestId = 0;
734
+ let workerState = "idle"; // idle | loading | index_ready | awaiting_consent | ready | error | dead
735
+ let searchable = false;
736
+ let requestSeq = 0;
737
+ const pending = new Map(); // requestId -> { resolve, reject }
738
+ let activeSearchId = 0;
85
739
  let isOpen = false;
86
740
  let selectedIndex = -1;
87
741
  let currentResults = [];
88
- let currentAnswer = null;
89
- let engineState = "idle"; // idle | loading | ready | error
90
742
  let lastHeartIndex = -1;
91
- let activeRequestId = 0;
92
- let searchPending = false;
743
+ let consentDeclined = false;
744
+ let lastDegradedNotice = null;
745
+ let lastAnnouncedQuarter = -1;
746
+ let manifestInfo = null;
747
+ let searchTimer = null;
748
+ let lastSearchedQuery = "";
749
+
750
+ let agentInfo = null; // null (unknown) | false | { maxBufferSize, hasF16 }
751
+ let agentWorker = null;
752
+ let agentModel = null; // { id, base, sizeBytes }
753
+ let agentLoaded = false;
754
+ let agentLoading = null; // Promise while a load is in flight
755
+ let agentRun = null; // { requestId, question, text, aborted }
756
+ let agentPendingLoad = null; // { resolve, reject }
93
757
 
94
758
  // -- DOM setup --
95
759
  const host = document.createElement("div");
96
760
  host.id = "eddie-host";
761
+ host.dataset.theme = config.theme;
762
+ host.dataset.state = workerState;
97
763
  const shadow = host.attachShadow({ mode: "closed" });
98
764
 
99
- // -- Styles --
765
+ /** The worker state is mirrored on the host as `data-state` (readable by the page). */
766
+ function setWorkerState(next) {
767
+ workerState = next;
768
+ host.dataset.state = next;
769
+ }
770
+
771
+ const DARK_TOKENS = `
772
+ --sa-bg: #1a1a1a;
773
+ --sa-bg-elevated: #252525;
774
+ --sa-text: #e8e8e8;
775
+ --sa-text-muted: #a3a3a3;
776
+ --sa-border: #333333;
777
+ --sa-accent: #60a5fa;
778
+ --sa-accent-soft: rgba(96, 165, 250, 0.12);
779
+ --sa-backdrop: rgba(0, 0, 0, 0.6);
780
+ --sa-shadow: 0 16px 48px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
781
+ --sa-error: #f87171;
782
+ --sa-error-bg: rgba(248, 113, 113, 0.1);
783
+ `;
784
+
100
785
  const style = document.createElement("style");
101
786
  style.textContent = `
102
787
  :host {
@@ -105,12 +790,14 @@
105
790
  --sa-bg: #ffffff;
106
791
  --sa-bg-elevated: #f6f6f6;
107
792
  --sa-text: #1a1a1a;
108
- --sa-text-muted: #6b6b6b;
793
+ --sa-text-muted: #5f5f5f;
109
794
  --sa-border: #e0e0e0;
110
795
  --sa-accent: #2563eb;
111
796
  --sa-accent-soft: rgba(37, 99, 235, 0.08);
112
797
  --sa-backdrop: rgba(0, 0, 0, 0.4);
113
798
  --sa-shadow: 0 16px 48px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
799
+ --sa-error: #b91c1c;
800
+ --sa-error-bg: rgba(185, 28, 28, 0.06);
114
801
  --sa-radius: 12px;
115
802
  --sa-radius-sm: 6px;
116
803
  --sa-trigger-size: 48px;
@@ -120,25 +807,21 @@
120
807
  position: fixed;
121
808
  z-index: 999999;
122
809
  }
123
-
810
+ :host([data-theme="dark"]) { ${DARK_TOKENS} }
124
811
  @media (prefers-color-scheme: dark) {
125
- :host {
126
- --sa-bg: #1a1a1a;
127
- --sa-bg-elevated: #252525;
128
- --sa-text: #e8e8e8;
129
- --sa-text-muted: #999999;
130
- --sa-border: #333333;
131
- --sa-accent: #60a5fa;
132
- --sa-accent-soft: rgba(96, 165, 250, 0.1);
133
- --sa-backdrop: rgba(0, 0, 0, 0.6);
134
- --sa-shadow: 0 16px 48px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
135
- }
812
+ :host([data-theme="auto"]) { ${DARK_TOKENS} }
136
813
  }
137
814
 
138
- *, *::before, *::after {
139
- box-sizing: border-box;
140
- margin: 0;
141
- padding: 0;
815
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
816
+
817
+ .sa-visually-hidden {
818
+ position: absolute !important;
819
+ width: 1px; height: 1px;
820
+ padding: 0; margin: -1px;
821
+ overflow: hidden;
822
+ clip: rect(0, 0, 0, 0);
823
+ white-space: nowrap;
824
+ border: 0;
142
825
  }
143
826
 
144
827
  .sa-trigger {
@@ -156,27 +839,14 @@
156
839
  box-shadow: 0 2px 12px rgba(0,0,0,0.1);
157
840
  transition: transform 0.15s ease, box-shadow 0.15s ease;
158
841
  }
159
- .sa-trigger:hover {
160
- transform: scale(1.06);
161
- box-shadow: 0 4px 16px rgba(0,0,0,0.15);
162
- }
163
- .sa-trigger:active {
164
- transform: scale(0.96);
165
- }
166
- .sa-trigger svg {
167
- width: 20px;
168
- height: 20px;
169
- stroke: currentColor;
170
- fill: none;
171
- stroke-width: 2;
172
- stroke-linecap: round;
173
- stroke-linejoin: round;
842
+ .sa-trigger:hover { transform: scale(1.06); box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
843
+ .sa-trigger:active { transform: scale(0.96); }
844
+ .sa-trigger:focus-visible, .sa-close:focus-visible, .sa-btn:focus-visible,
845
+ .sa-result:focus-visible, .sa-cite:focus-visible, .sa-answer-cite:focus-visible {
846
+ outline: 2px solid var(--sa-accent);
847
+ outline-offset: 2px;
174
848
  }
175
-
176
- .sa-pos-bottom-right { right: 24px; bottom: 24px; }
177
- .sa-pos-bottom-left { left: 24px; bottom: 24px; }
178
- .sa-pos-top-right { right: 24px; top: 24px; }
179
- .sa-pos-top-left { left: 24px; top: 24px; }
849
+ .sa-trigger svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
180
850
 
181
851
  .sa-backdrop {
182
852
  position: fixed;
@@ -187,18 +857,17 @@
187
857
  justify-content: center;
188
858
  padding-top: 12vh;
189
859
  }
190
- .sa-backdrop.sa-open {
191
- display: flex;
192
- }
860
+ .sa-backdrop.sa-open { display: flex; }
193
861
 
194
862
  .sa-modal {
195
863
  background: var(--sa-bg);
864
+ color: var(--sa-text);
196
865
  border: 1px solid var(--sa-border);
197
866
  border-radius: var(--sa-radius);
198
867
  box-shadow: var(--sa-shadow);
199
868
  width: 100%;
200
- max-width: 600px;
201
- max-height: 72vh;
869
+ max-width: 620px;
870
+ max-height: 76vh;
202
871
  display: flex;
203
872
  flex-direction: column;
204
873
  overflow: hidden;
@@ -212,24 +881,14 @@
212
881
  .sa-header {
213
882
  display: flex;
214
883
  align-items: center;
215
- padding: 16px;
216
- gap: 12px;
884
+ padding: 12px 16px;
885
+ gap: 10px;
217
886
  border-bottom: 1px solid var(--sa-border);
218
887
  }
219
-
220
- .sa-search-icon {
221
- flex-shrink: 0;
222
- width: 18px;
223
- height: 18px;
224
- stroke: var(--sa-text-muted);
225
- fill: none;
226
- stroke-width: 2;
227
- stroke-linecap: round;
228
- stroke-linejoin: round;
229
- }
230
-
888
+ .sa-search-icon { flex-shrink: 0; width: 18px; height: 18px; stroke: var(--sa-text-muted); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
231
889
  .sa-input {
232
890
  flex: 1;
891
+ min-width: 0;
233
892
  border: none;
234
893
  background: none;
235
894
  font-family: var(--sa-font);
@@ -237,14 +896,33 @@
237
896
  color: var(--sa-text);
238
897
  outline: none;
239
898
  }
240
- .sa-input::placeholder {
241
- color: var(--sa-text-muted);
899
+ .sa-input::placeholder { color: var(--sa-text-muted); }
900
+
901
+ .sa-btn {
902
+ flex-shrink: 0;
903
+ border-radius: var(--sa-radius-sm);
904
+ border: 1px solid var(--sa-border);
905
+ background: var(--sa-bg-elevated);
906
+ color: var(--sa-text);
907
+ cursor: pointer;
908
+ font-family: var(--sa-font);
909
+ font-size: 12px;
910
+ padding: 5px 10px;
911
+ line-height: 1.2;
912
+ transition: border-color 0.1s;
242
913
  }
914
+ .sa-btn:hover { border-color: var(--sa-text-muted); }
915
+ .sa-btn:disabled { opacity: 0.5; cursor: default; }
916
+ .sa-btn-primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
917
+ .sa-btn-primary:hover { filter: brightness(1.08); border-color: var(--sa-accent); }
918
+ .sa-ask { font-weight: 600; }
919
+ .sa-ask[hidden] { display: none; }
243
920
 
244
921
  .sa-close {
245
922
  flex-shrink: 0;
246
- width: 28px;
247
923
  height: 28px;
924
+ min-width: 28px;
925
+ padding: 0 6px;
248
926
  border-radius: var(--sa-radius-sm);
249
927
  border: 1px solid var(--sa-border);
250
928
  background: var(--sa-bg-elevated);
@@ -258,22 +936,12 @@
258
936
  line-height: 1;
259
937
  transition: border-color 0.1s;
260
938
  }
261
- .sa-close:hover {
262
- border-color: var(--sa-text-muted);
263
- }
939
+ .sa-close:hover { border-color: var(--sa-text-muted); }
264
940
 
265
- .sa-heart {
266
- flex-shrink: 0;
267
- width: 14px;
268
- height: 8px;
269
- image-rendering: pixelated;
270
- image-rendering: crisp-edges;
271
- opacity: 0.95;
272
- display: block;
273
- }
941
+ .sa-heart { flex-shrink: 0; width: 14px; height: 8px; image-rendering: pixelated; image-rendering: crisp-edges; opacity: 0.95; display: block; }
274
942
 
275
943
  .sa-status {
276
- padding: 12px 16px;
944
+ padding: 10px 16px;
277
945
  font-size: 13px;
278
946
  color: var(--sa-text-muted);
279
947
  display: none;
@@ -281,80 +949,84 @@
281
949
  gap: 10px;
282
950
  border-bottom: 1px solid var(--sa-border);
283
951
  }
284
- .sa-status.sa-visible {
285
- display: flex;
286
- }
287
-
288
- .sa-progress-bar {
289
- flex: 1;
290
- height: 3px;
291
- background: var(--sa-bg-elevated);
292
- border-radius: 2px;
293
- overflow: hidden;
294
- }
295
- .sa-progress-fill {
296
- height: 100%;
297
- background: var(--sa-accent);
298
- border-radius: 2px;
299
- width: 0%;
300
- transition: width 0.2s ease;
301
- }
302
- .sa-progress-indeterminate .sa-progress-fill {
303
- width: 40%;
304
- animation: sa-indeterminate 1.2s ease-in-out infinite;
305
- }
952
+ .sa-status.sa-visible { display: flex; }
953
+ .sa-status-text { flex-shrink: 0; }
954
+ .sa-progress-bar { flex: 1; height: 3px; background: var(--sa-bg-elevated); border-radius: 2px; overflow: hidden; }
955
+ .sa-progress-fill { height: 100%; background: var(--sa-accent); border-radius: 2px; width: 0%; transition: width 0.2s ease; }
956
+ .sa-progress-indeterminate .sa-progress-fill { width: 40%; animation: sa-indeterminate 1.2s ease-in-out infinite; }
306
957
  @keyframes sa-indeterminate {
307
958
  0% { transform: translateX(-100%); }
308
959
  100% { transform: translateX(350%); }
309
960
  }
310
961
 
311
- .sa-results {
312
- flex: 1;
313
- overflow-y: auto;
314
- list-style: none;
315
- }
316
-
317
- .sa-answer {
962
+ .sa-card {
318
963
  display: none;
319
964
  border-bottom: 1px solid var(--sa-border);
320
965
  background: var(--sa-bg-elevated);
321
966
  padding: 12px 16px;
322
- gap: 6px;
323
967
  flex-direction: column;
968
+ gap: 8px;
969
+ font-size: 13px;
970
+ line-height: 1.45;
971
+ color: var(--sa-text);
324
972
  }
325
- .sa-answer.sa-visible {
326
- display: flex;
327
- }
328
- .sa-answer-label {
973
+ .sa-card.sa-visible { display: flex; }
974
+ .sa-card-label {
329
975
  font-size: 11px;
330
976
  letter-spacing: 0.08em;
331
977
  text-transform: uppercase;
332
978
  color: var(--sa-text-muted);
333
979
  font-family: var(--sa-font-mono);
334
- }
335
- .sa-answer-text {
336
- font-size: 14px;
337
- line-height: 1.45;
338
- color: var(--sa-text);
339
- }
340
- .sa-answer-cites {
341
980
  display: flex;
342
- flex-wrap: wrap;
981
+ align-items: center;
343
982
  gap: 8px;
344
- margin-top: 2px;
345
- }
346
- .sa-answer-cite {
347
- font-size: 11px;
348
- color: var(--sa-accent);
349
- text-decoration: none;
350
- border: 1px solid var(--sa-border);
351
- border-radius: 999px;
352
- padding: 2px 8px;
983
+ flex-wrap: wrap;
353
984
  }
354
- .sa-answer-cite:hover {
355
- border-color: var(--sa-accent);
985
+ .sa-card-label .sa-model { text-transform: none; letter-spacing: 0; }
986
+ .sa-card-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
987
+ .sa-muted { color: var(--sa-text-muted); }
988
+
989
+ .sa-error {
990
+ padding: 10px 16px;
991
+ font-size: 13px;
992
+ color: var(--sa-error);
993
+ background: var(--sa-error-bg);
994
+ border-bottom: 1px solid var(--sa-border);
995
+ display: none;
996
+ align-items: center;
997
+ gap: 10px;
998
+ flex-wrap: wrap;
356
999
  }
1000
+ .sa-error.sa-visible { display: flex; }
1001
+ .sa-error .sa-btn { color: var(--sa-text); }
357
1002
 
1003
+ .sa-notice {
1004
+ padding: 6px 16px;
1005
+ font-size: 12px;
1006
+ color: var(--sa-text-muted);
1007
+ border-bottom: 1px solid var(--sa-border);
1008
+ display: none;
1009
+ }
1010
+ .sa-notice.sa-visible { display: block; }
1011
+
1012
+ .sa-answer-text:not(.sa-visible), .sa-sources:not(.sa-visible), .sa-answer-progress:not(.sa-visible) { display: none; }
1013
+ .sa-answer-text { font-size: 14px; line-height: 1.5; color: var(--sa-text); white-space: pre-wrap; }
1014
+ .sa-answer-text.sa-nohit { color: var(--sa-text-muted); font-style: italic; }
1015
+ .sa-answer-cite { color: var(--sa-accent); text-decoration: none; font-family: var(--sa-font-mono); font-size: 12px; }
1016
+ .sa-answer-cite:hover { text-decoration: underline; }
1017
+ .sa-caret { display: inline-block; width: 0.5em; height: 1em; vertical-align: text-bottom; background: var(--sa-accent); animation: sa-blink 1s steps(2) infinite; margin-left: 2px; }
1018
+ @keyframes sa-blink { to { opacity: 0; } }
1019
+ .sa-sources { list-style: none; display: flex; flex-direction: column; gap: 3px; }
1020
+ .sa-sources li { font-size: 12px; }
1021
+ .sa-cite { color: var(--sa-accent); text-decoration: none; }
1022
+ .sa-cite:hover { text-decoration: underline; }
1023
+ .sa-cite-n { font-family: var(--sa-font-mono); color: var(--sa-text-muted); margin-right: 6px; }
1024
+ .sa-faq-q { font-weight: 600; }
1025
+
1026
+ .sa-results { flex: 1; overflow-y: auto; list-style: none; }
1027
+ .sa-empty { padding: 32px 16px; text-align: center; color: var(--sa-text-muted); font-size: 14px; display: none; }
1028
+ .sa-empty.sa-visible { display: block; }
1029
+ .sa-option { list-style: none; }
358
1030
  .sa-result {
359
1031
  display: block;
360
1032
  padding: 12px 16px;
@@ -364,61 +1036,23 @@
364
1036
  color: inherit;
365
1037
  transition: background 0.08s;
366
1038
  }
367
- .sa-result:last-child {
368
- border-bottom: none;
369
- }
370
- .sa-result:hover,
371
- .sa-result[aria-selected="true"] {
372
- background: var(--sa-accent-soft);
373
- }
374
- .sa-result-title {
375
- font-size: 14px;
376
- font-weight: 600;
377
- color: var(--sa-text);
378
- margin-bottom: 2px;
379
- }
380
- .sa-result-url {
381
- font-family: var(--sa-font-mono);
382
- font-size: 11px;
383
- color: var(--sa-accent);
384
- margin-bottom: 4px;
385
- }
386
- .sa-result-section {
387
- font-size: 11px;
388
- color: var(--sa-text-muted);
389
- margin-bottom: 4px;
390
- }
391
- .sa-result-snippet {
392
- font-size: 13px;
393
- color: var(--sa-text-muted);
394
- line-height: 1.45;
395
- }
396
-
397
- .sa-empty {
398
- padding: 32px 16px;
399
- text-align: center;
400
- color: var(--sa-text-muted);
401
- font-size: 14px;
402
- }
403
-
404
- .sa-error {
405
- padding: 12px 16px;
406
- font-size: 13px;
407
- color: #dc2626;
408
- display: none;
409
- }
410
- .sa-error.sa-visible {
411
- display: block;
412
- }
413
-
414
- .sa-footer {
415
- padding: 8px 16px;
416
- border-top: 1px solid var(--sa-border);
417
- display: flex;
418
- align-items: center;
419
- justify-content: space-between;
1039
+ .sa-option:last-child .sa-result { border-bottom: none; }
1040
+ .sa-result:hover, .sa-option[aria-selected="true"] .sa-result { background: var(--sa-accent-soft); }
1041
+ .sa-result-title { font-size: 14px; font-weight: 600; color: var(--sa-text); margin-bottom: 2px; }
1042
+ .sa-result-url { font-family: var(--sa-font-mono); font-size: 11px; color: var(--sa-accent); margin-bottom: 4px; word-break: break-all; }
1043
+ .sa-result-section { font-size: 11px; color: var(--sa-text-muted); margin-bottom: 4px; }
1044
+ .sa-result-snippet { font-size: 13px; color: var(--sa-text-muted); line-height: 1.45; }
1045
+
1046
+ .sa-footer {
1047
+ padding: 8px 16px;
1048
+ border-top: 1px solid var(--sa-border);
1049
+ display: flex;
1050
+ align-items: center;
1051
+ justify-content: space-between;
420
1052
  font-size: 11px;
421
1053
  color: var(--sa-text-muted);
1054
+ gap: 8px;
1055
+ flex-wrap: wrap;
422
1056
  }
423
1057
  .sa-footer kbd {
424
1058
  display: inline-block;
@@ -430,15 +1064,7 @@
430
1064
  background: var(--sa-bg-elevated);
431
1065
  margin: 0 2px;
432
1066
  }
433
-
434
- .sa-brand {
435
- display: inline-flex;
436
- align-items: center;
437
- gap: 6px;
438
- letter-spacing: 0.08em;
439
- font-weight: 600;
440
- }
441
-
1067
+ .sa-brand { display: inline-flex; align-items: center; gap: 6px; letter-spacing: 0.08em; font-weight: 600; }
442
1068
  .sa-brand-link {
443
1069
  color: var(--sa-text-muted);
444
1070
  text-decoration: none;
@@ -447,28 +1073,19 @@
447
1073
  padding: 2px 8px;
448
1074
  transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
449
1075
  }
450
- .sa-brand-link:hover {
451
- border-color: var(--sa-text-muted);
452
- color: var(--sa-text);
453
- background: var(--sa-bg-elevated);
454
- }
455
- .sa-brand-link:focus-visible {
456
- outline: 1px solid var(--sa-accent);
457
- outline-offset: 2px;
1076
+ .sa-brand-link:hover { border-color: var(--sa-text-muted); color: var(--sa-text); background: var(--sa-bg-elevated); }
1077
+ .sa-brand-link:focus-visible { outline: 1px solid var(--sa-accent); outline-offset: 2px; }
1078
+
1079
+ @media (prefers-reduced-motion: reduce) {
1080
+ .sa-modal { animation: none; }
1081
+ .sa-trigger, .sa-progress-fill, .sa-result, .sa-btn, .sa-close, .sa-brand-link { transition: none; }
1082
+ .sa-progress-indeterminate .sa-progress-fill { animation: none; width: 100%; opacity: 0.5; }
1083
+ .sa-caret { animation: none; }
458
1084
  }
459
1085
 
460
- /* Mobile: bottom sheet */
461
1086
  @media (max-width: 640px) {
462
- .sa-backdrop {
463
- padding-top: 0;
464
- align-items: flex-end;
465
- }
466
- .sa-modal {
467
- max-width: 100%;
468
- max-height: 85vh;
469
- border-radius: var(--sa-radius) var(--sa-radius) 0 0;
470
- animation-name: sa-slide-up;
471
- }
1087
+ .sa-backdrop { padding-top: 0; align-items: flex-end; }
1088
+ .sa-modal { max-width: 100%; max-height: 85vh; border-radius: var(--sa-radius) var(--sa-radius) 0 0; animation-name: sa-slide-up; }
472
1089
  @keyframes sa-slide-up {
473
1090
  from { opacity: 0; transform: translateY(40px); }
474
1091
  to { opacity: 1; transform: translateY(0); }
@@ -477,11 +1094,27 @@
477
1094
  `;
478
1095
  shadow.appendChild(style);
479
1096
 
480
- // -- SVG helper --
1097
+ // -- Element helpers --
1098
+ function el(tag, className, text) {
1099
+ const node = document.createElement(tag);
1100
+ if (className) node.className = className;
1101
+ if (text != null) node.textContent = text;
1102
+ return node;
1103
+ }
1104
+
1105
+ function button(className, text, onClick, attrs) {
1106
+ const b = el("button", className, text);
1107
+ b.type = "button";
1108
+ if (onClick) b.addEventListener("click", onClick);
1109
+ if (attrs) Object.keys(attrs).forEach((k) => b.setAttribute(k, attrs[k]));
1110
+ return b;
1111
+ }
1112
+
481
1113
  function createSearchSvg(className) {
482
1114
  const ns = "http://www.w3.org/2000/svg";
483
1115
  const svg = document.createElementNS(ns, "svg");
484
1116
  svg.setAttribute("viewBox", "0 0 24 24");
1117
+ svg.setAttribute("aria-hidden", "true");
485
1118
  if (className) svg.setAttribute("class", className);
486
1119
  const circle = document.createElementNS(ns, "circle");
487
1120
  circle.setAttribute("cx", "11");
@@ -498,49 +1131,51 @@
498
1131
  }
499
1132
 
500
1133
  // -- Trigger button --
501
- const trigger = document.createElement("button");
502
- trigger.className = `sa-trigger sa-pos-${config.position}`;
503
- trigger.setAttribute("aria-label", "Search");
1134
+ const trigger = button(`sa-trigger sa-pos-${config.position}`, null, openModal, { "aria-label": "Search" });
504
1135
  trigger.appendChild(createSearchSvg());
505
- trigger.addEventListener("click", openModal);
506
1136
  applyTriggerOffsets();
507
1137
  shadow.appendChild(trigger);
508
1138
 
509
- // -- Backdrop --
510
- const backdrop = document.createElement("div");
511
- backdrop.className = "sa-backdrop";
1139
+ // -- Backdrop + modal --
1140
+ const backdrop = el("div", "sa-backdrop");
512
1141
  backdrop.addEventListener("click", (e) => {
513
1142
  if (e.target === backdrop) closeModal();
514
1143
  });
515
1144
  shadow.appendChild(backdrop);
516
1145
 
517
- // -- Modal --
518
- const modal = document.createElement("div");
519
- modal.className = "sa-modal";
1146
+ const modal = el("div", "sa-modal");
520
1147
  modal.setAttribute("role", "dialog");
521
1148
  modal.setAttribute("aria-modal", "true");
522
1149
  modal.setAttribute("aria-label", "Search");
523
1150
  backdrop.appendChild(modal);
524
1151
 
525
1152
  // Header
526
- const header = document.createElement("div");
527
- header.className = "sa-header";
1153
+ const header = el("div", "sa-header");
528
1154
  header.appendChild(createSearchSvg("sa-search-icon"));
529
1155
  modal.appendChild(header);
530
1156
 
531
- const input = document.createElement("input");
532
- input.className = "sa-input";
1157
+ const input = el("input", "sa-input");
533
1158
  input.type = "text";
534
- input.setAttribute("role", "searchbox");
1159
+ input.setAttribute("role", "combobox");
535
1160
  input.setAttribute("aria-label", "Search query");
536
- input.placeholder = "Search\u2026";
1161
+ input.setAttribute("aria-autocomplete", "list");
1162
+ input.setAttribute("aria-haspopup", "listbox");
1163
+ input.setAttribute("aria-expanded", "false");
1164
+ input.setAttribute("aria-controls", "sa-results");
1165
+ input.setAttribute("autocomplete", "off");
1166
+ input.setAttribute("spellcheck", "false");
1167
+ input.placeholder = "Search…";
537
1168
  header.appendChild(input);
538
1169
 
539
- const closeBtn = document.createElement("button");
540
- closeBtn.className = "sa-close";
541
- closeBtn.setAttribute("aria-label", "Close");
542
- closeBtn.textContent = "esc";
543
- closeBtn.addEventListener("click", closeModal);
1170
+ const askBtn = button("sa-btn sa-ask", "Ask", () => askQuestion(input.value.trim()), {
1171
+ "aria-label": "Ask a question about this site (Shift+Enter)",
1172
+ title: "Ask (Shift+Enter)",
1173
+ });
1174
+ askBtn.hidden = true;
1175
+ header.appendChild(askBtn);
1176
+
1177
+ const closeBtn = button("sa-close", "esc", closeModal, { "aria-label": "Close (Esc)" });
1178
+ header.appendChild(closeBtn);
544
1179
 
545
1180
  const heart = document.createElement("canvas");
546
1181
  heart.className = "sa-heart";
@@ -548,81 +1183,150 @@
548
1183
  heart.height = 4;
549
1184
  heart.setAttribute("aria-hidden", "true");
550
1185
  drawHeartSprite(0);
551
- header.appendChild(closeBtn);
552
1186
 
553
- // Status bar
554
- const status = document.createElement("div");
555
- status.className = "sa-status";
556
- modal.appendChild(status);
1187
+ // Live region for screen readers (loading, progress, counts, answers)
1188
+ const liveRegion = el("div", "sa-visually-hidden");
1189
+ liveRegion.setAttribute("role", "status");
1190
+ liveRegion.setAttribute("aria-live", "polite");
1191
+ liveRegion.setAttribute("aria-atomic", "true");
1192
+ modal.appendChild(liveRegion);
557
1193
 
558
- const statusText = document.createElement("span");
1194
+ // Status bar (visible progress)
1195
+ const status = el("div", "sa-status");
1196
+ const statusText = el("span", "sa-status-text");
559
1197
  status.appendChild(statusText);
560
-
561
- const progressBar = document.createElement("div");
562
- progressBar.className = "sa-progress-bar";
563
- const progressFill = document.createElement("div");
564
- progressFill.className = "sa-progress-fill";
1198
+ const progressBar = el("div", "sa-progress-bar");
1199
+ progressBar.setAttribute("role", "progressbar");
1200
+ progressBar.setAttribute("aria-label", "Loading progress");
1201
+ progressBar.setAttribute("aria-valuemin", "0");
1202
+ progressBar.setAttribute("aria-valuemax", "100");
1203
+ const progressFill = el("div", "sa-progress-fill");
565
1204
  progressBar.appendChild(progressFill);
566
1205
  status.appendChild(progressBar);
1206
+ modal.appendChild(status);
1207
+
1208
+ // Download consent card
1209
+ const consentCard = el("div", "sa-card");
1210
+ const consentLabel = el("div", "sa-card-label", "Download the search model?");
1211
+ const consentText = el("div");
1212
+ const consentActions = el("div", "sa-card-actions");
1213
+ const consentAccept = button("sa-btn sa-btn-primary", "Download", acceptConsent);
1214
+ const consentDecline = button("sa-btn", "Keyword search only", declineConsent);
1215
+ consentActions.appendChild(consentAccept);
1216
+ consentActions.appendChild(consentDecline);
1217
+ consentCard.appendChild(consentLabel);
1218
+ consentCard.appendChild(consentText);
1219
+ consentCard.appendChild(consentActions);
1220
+ modal.appendChild(consentCard);
567
1221
 
568
1222
  // Error area
569
- const errorEl = document.createElement("div");
570
- errorEl.className = "sa-error";
1223
+ const errorEl = el("div", "sa-error");
1224
+ errorEl.setAttribute("role", "alert");
1225
+ const errorText = el("span");
1226
+ const retryBtn = button("sa-btn", "Retry", retryInit);
1227
+ errorEl.appendChild(errorText);
1228
+ errorEl.appendChild(retryBtn);
571
1229
  modal.appendChild(errorEl);
572
1230
 
573
- // Answer area (experimental factual mode)
574
- const answerEl = document.createElement("div");
575
- answerEl.className = "sa-answer";
576
- modal.appendChild(answerEl);
1231
+ // Degraded notice
1232
+ const noticeEl = el("div", "sa-notice");
1233
+ modal.appendChild(noticeEl);
1234
+
1235
+ // Answer card (agent)
1236
+ const answerCard = el("div", "sa-card sa-answer");
1237
+ const answerLabel = el("div", "sa-card-label");
1238
+ const answerLabelText = el("span", null, "Answer");
1239
+ const answerModel = el("span", "sa-model");
1240
+ answerLabel.appendChild(answerLabelText);
1241
+ answerLabel.appendChild(answerModel);
1242
+ const answerProgress = el("div", "sa-answer-progress sa-muted");
1243
+ const answerText = el("div", "sa-answer-text");
1244
+ const answerSources = el("ol", "sa-sources");
1245
+ const answerActions = el("div", "sa-card-actions");
1246
+ const stopBtn = button("sa-btn", "Stop", () => abortAgent("stopped"));
1247
+ // The three agent buttons get a fresh `onclick` each time they are shown.
1248
+ const answerRetryBtn = button("sa-btn", "Retry", null);
1249
+ const agentConsentAccept = button("sa-btn sa-btn-primary", "Download and answer", null);
1250
+ const agentConsentCancel = button("sa-btn", "Cancel", null);
1251
+ agentConsentCancel.onclick = hideAnswerCard;
1252
+ answerCard.appendChild(answerLabel);
1253
+ answerCard.appendChild(answerProgress);
1254
+ answerCard.appendChild(answerText);
1255
+ answerCard.appendChild(answerSources);
1256
+ answerCard.appendChild(answerActions);
1257
+ modal.appendChild(answerCard);
1258
+
1259
+ // FAQ card (qa_lookup hits)
1260
+ const faqCard = el("div", "sa-card");
1261
+ faqCard.appendChild(el("div", "sa-card-label", "From the FAQ"));
1262
+ const faqQ = el("div", "sa-faq-q");
1263
+ const faqA = el("div");
1264
+ const faqSrc = el("div");
1265
+ faqCard.appendChild(faqQ);
1266
+ faqCard.appendChild(faqA);
1267
+ faqCard.appendChild(faqSrc);
1268
+ modal.appendChild(faqCard);
1269
+
1270
+ // Empty state (outside the listbox)
1271
+ const emptyEl = el("div", "sa-empty", "No results found.");
1272
+ modal.appendChild(emptyEl);
577
1273
 
578
1274
  // Results
579
- const resultsList = document.createElement("ul");
580
- resultsList.className = "sa-results";
1275
+ const resultsList = el("ul", "sa-results");
1276
+ resultsList.id = "sa-results";
581
1277
  resultsList.setAttribute("role", "listbox");
1278
+ resultsList.setAttribute("aria-label", "Search results");
582
1279
  modal.appendChild(resultsList);
583
1280
 
584
- // Footer (built with DOM, not innerHTML)
585
- const footer = document.createElement("div");
586
- footer.className = "sa-footer";
587
-
588
- const footerNav = document.createElement("span");
589
- const keys = [
590
- ["\u2191", ""],
591
- ["\u2193", " navigate "],
592
- ["enter", " open"],
593
- ];
1281
+ // Footer
1282
+ const footer = el("div", "sa-footer");
1283
+ const footerNav = el("span");
1284
+ const keys = [["↑", ""], ["↓", " navigate "], ["enter", " open"]];
594
1285
  keys.forEach(([key, after]) => {
595
- const kbd = document.createElement("kbd");
596
- kbd.textContent = key;
1286
+ const kbd = el("kbd", null, key);
597
1287
  footerNav.appendChild(kbd);
598
1288
  if (after) footerNav.appendChild(document.createTextNode(after));
599
1289
  });
1290
+ const askHint = el("span");
1291
+ askHint.hidden = true;
1292
+ askHint.appendChild(document.createTextNode(" "));
1293
+ askHint.appendChild(el("kbd", null, "shift+enter"));
1294
+ askHint.appendChild(document.createTextNode(" ask"));
1295
+ footerNav.appendChild(askHint);
600
1296
  footer.appendChild(footerNav);
601
1297
 
602
- const footerBrandLink = document.createElement("a");
603
- footerBrandLink.className = "sa-brand-link";
1298
+ const footerBrandLink = el("a", "sa-brand-link");
604
1299
  footerBrandLink.href = "https://github.com/jt55401/eddie";
605
1300
  footerBrandLink.target = "_blank";
606
1301
  footerBrandLink.rel = "noopener noreferrer";
607
1302
  footerBrandLink.setAttribute("aria-label", "Eddie on GitHub (opens in a new tab)");
608
-
609
- const footerBrand = document.createElement("span");
610
- footerBrand.className = "sa-brand";
1303
+ const footerBrand = el("span", "sa-brand");
611
1304
  footerBrand.appendChild(document.createTextNode("EDDIE"));
612
1305
  footerBrand.appendChild(heart);
613
1306
  footerBrandLink.appendChild(footerBrand);
614
1307
  footer.appendChild(footerBrandLink);
615
-
616
1308
  modal.appendChild(footer);
617
1309
 
618
1310
  // -- Keyboard handling --
619
1311
  input.addEventListener("keydown", (e) => {
620
1312
  if (e.key === "Enter") {
621
1313
  e.preventDefault();
1314
+ const q = input.value.trim();
1315
+ if (e.shiftKey) {
1316
+ if (q && !askBtn.hidden) {
1317
+ // Flush the pending search-as-you-type run so it neither races the
1318
+ // answer nor fires 200 ms later and cancels it.
1319
+ clearTimeout(searchTimer);
1320
+ if (lastSearchedQuery !== q) doSearch(q, true);
1321
+ askQuestion(q);
1322
+ }
1323
+ return;
1324
+ }
622
1325
  if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
623
- navigateToResult(currentResults[selectedIndex]);
624
- } else if (input.value.trim()) {
625
- doSearch(input.value.trim());
1326
+ navigateTo(currentResults[selectedIndex].url);
1327
+ } else if (q) {
1328
+ clearTimeout(searchTimer);
1329
+ doSearch(q, true);
626
1330
  }
627
1331
  } else if (e.key === "ArrowDown") {
628
1332
  e.preventDefault();
@@ -630,25 +1334,22 @@
630
1334
  } else if (e.key === "ArrowUp") {
631
1335
  e.preventDefault();
632
1336
  moveSelection(-1);
633
- } else if (e.key === "Escape") {
634
- e.preventDefault();
635
- closeModal();
636
1337
  }
637
1338
  });
638
1339
 
639
- // Debounced search-as-you-type
640
- let searchTimer = null;
1340
+ // Search-as-you-type: retrieval only, never the agent.
641
1341
  input.addEventListener("input", () => {
642
1342
  clearTimeout(searchTimer);
643
1343
  const q = input.value.trim();
644
- if (q.length >= 2 && engineState === "ready") {
645
- searchTimer = setTimeout(() => doSearch(q), 200);
1344
+ setSelected(-1);
1345
+ if (q.length >= 2) {
1346
+ searchTimer = setTimeout(() => doSearch(q, false), 200);
646
1347
  } else if (q.length === 0) {
647
1348
  clearResults();
648
1349
  }
649
1350
  });
650
1351
 
651
- // Focus trap
1352
+ // Esc closes; Tab cycles through everything focusable in the modal.
652
1353
  modal.addEventListener("keydown", (e) => {
653
1354
  if (e.key === "Escape") {
654
1355
  e.preventDefault();
@@ -656,264 +1357,834 @@
656
1357
  return;
657
1358
  }
658
1359
  if (e.key !== "Tab") return;
659
-
660
- const focusable = [input, closeBtn];
1360
+ const focusable = Array.from(modal.querySelectorAll("input, button, a[href]")).filter(isVisible);
1361
+ if (focusable.length === 0) return;
661
1362
  const first = focusable[0];
662
1363
  const last = focusable[focusable.length - 1];
663
-
664
- if (e.shiftKey) {
665
- if (shadow.activeElement === first) {
666
- e.preventDefault();
667
- last.focus();
668
- }
669
- } else {
670
- if (shadow.activeElement === last) {
671
- e.preventDefault();
672
- first.focus();
673
- }
1364
+ const current = shadow.activeElement;
1365
+ if (e.shiftKey && (current === first || !focusable.includes(current))) {
1366
+ e.preventDefault();
1367
+ last.focus();
1368
+ } else if (!e.shiftKey && current === last) {
1369
+ e.preventDefault();
1370
+ first.focus();
674
1371
  }
675
1372
  });
676
1373
 
677
- // -- Worker communication --
678
- function ensureWorker() {
679
- if (worker) return;
680
-
681
- worker = new Worker(resolveAsset("eddie-worker.js"));
682
- worker.onmessage = (e) => {
683
- const msg = e.data;
1374
+ function isVisible(node) {
1375
+ if (node.hidden || node.disabled) return false;
1376
+ let n = node;
1377
+ while (n && n !== modal) {
1378
+ if (n.hidden) return false;
1379
+ const cs = getComputedStyle(n);
1380
+ if (cs.display === "none" || cs.visibility === "hidden") return false;
1381
+ n = n.parentElement;
1382
+ }
1383
+ return true;
1384
+ }
684
1385
 
685
- if (msg.type === "status") {
686
- handleStatus(msg);
687
- } else if (msg.type === "search_result") {
688
- handleSearchResult(msg);
689
- } else if (msg.type === "error") {
690
- handleError(msg);
691
- }
1386
+ // -- Search worker --
1387
+ function ensureWorker() {
1388
+ if (worker) {
1389
+ if (workerState === "error") retryInit();
1390
+ return;
1391
+ }
1392
+ try {
1393
+ worker = new Worker(lib.assetUrl(baseUrl, "eddie-worker.js", version));
1394
+ } catch (err) {
1395
+ showInitError("Couldn't start the search worker: " + (err && err.message ? err.message : err), false);
1396
+ return;
1397
+ }
1398
+ worker.onmessage = (e) => onWorkerMessage(e.data || {});
1399
+ worker.onerror = (e) => {
1400
+ // An uncaught error in the worker script (a 404, a syntax error, an
1401
+ // exception outside a handler): start a fresh worker on Retry.
1402
+ const message = e && e.message ? e.message : "search worker failed to load";
1403
+ setWorkerState("dead");
1404
+ searchable = false;
1405
+ showStatus(false);
1406
+ showInitError(message, true);
1407
+ failAllPending(new Error(message));
692
1408
  };
1409
+ postInit(false);
1410
+ }
693
1411
 
694
- engineState = "loading";
1412
+ function postInit(consent) {
1413
+ setWorkerState("loading");
1414
+ hide(errorEl);
695
1415
  worker.postMessage({
696
1416
  type: "init",
697
- indexUrl: new URL(config.indexUrl, location.href).href,
698
- baseUrl: baseUrl,
1417
+ indexUrl,
1418
+ baseUrl,
1419
+ version,
1420
+ denseRuntime: config.denseRuntime,
1421
+ consent: !!consent,
699
1422
  });
700
1423
  }
701
1424
 
702
- function handleStatus(msg) {
703
- const stateLabels = {
704
- loading_wasm: "Loading search engine\u2026",
705
- loading_index: "Loading index\u2026",
706
- checking_cache: "Checking model cache\u2026",
707
- downloading_model: "Downloading model\u2026",
708
- initializing: "Initializing\u2026",
709
- ready: "Ready",
710
- };
711
-
712
- if (msg.state === "ready") {
713
- engineState = "ready";
714
- showStatus(false);
715
- // If there's already a query waiting, run it
716
- if (input.value.trim().length >= 2) {
717
- doSearch(input.value.trim());
718
- }
1425
+ function retryInit() {
1426
+ hide(errorEl);
1427
+ if (!worker || workerState === "dead") {
1428
+ if (worker) worker.terminate();
1429
+ worker = null;
1430
+ searchable = false;
1431
+ setWorkerState("idle");
1432
+ ensureWorker();
719
1433
  return;
720
1434
  }
1435
+ postInit(false);
1436
+ }
721
1437
 
722
- if (msg.state === "error") {
723
- engineState = "error";
724
- showError(msg.error || "Failed to initialize");
725
- showStatus(false);
726
- return;
727
- }
1438
+ function callWorker(type, payload) {
1439
+ return new Promise((resolve, reject) => {
1440
+ if (!worker) {
1441
+ reject(new Error("search worker not running"));
1442
+ return;
1443
+ }
1444
+ const requestId = ++requestSeq;
1445
+ pending.set(requestId, { resolve, reject });
1446
+ worker.postMessage(Object.assign({ type, requestId }, payload || {}));
1447
+ });
1448
+ }
728
1449
 
729
- engineState = "loading";
730
- statusText.textContent = stateLabels[msg.state] || msg.state;
731
- showStatus(true);
1450
+ function failAllPending(err) {
1451
+ pending.forEach((p) => p.reject(err));
1452
+ pending.clear();
1453
+ }
732
1454
 
733
- if (msg.state === "downloading_model" && msg.progress != null) {
734
- progressBar.classList.remove("sa-progress-indeterminate");
735
- progressFill.style.width = Math.round(msg.progress * 100) + "%";
736
- statusText.textContent =
737
- "Downloading " + (msg.file || "model") + "\u2026 " +
738
- Math.round(msg.progress * 100) + "%";
739
- } else {
740
- progressBar.classList.add("sa-progress-indeterminate");
741
- progressFill.style.width = "";
1455
+ function onWorkerMessage(msg) {
1456
+ if (msg.requestId != null && pending.has(msg.requestId)) {
1457
+ const p = pending.get(msg.requestId);
1458
+ pending.delete(msg.requestId);
1459
+ if (msg.type === "error") {
1460
+ const err = new Error(msg.message || "request failed");
1461
+ err.fatal = !!msg.fatal;
1462
+ p.reject(err);
1463
+ if (msg.fatal) onFatal(msg.message);
1464
+ } else {
1465
+ p.resolve(msg);
1466
+ }
1467
+ return;
1468
+ }
1469
+ switch (msg.type) {
1470
+ case "status":
1471
+ handleStatus(msg);
1472
+ break;
1473
+ case "ready":
1474
+ handleReady(msg);
1475
+ break;
1476
+ case "error":
1477
+ if (msg.fatal) onFatal(msg.message);
1478
+ else showInitError(msg.message || "Search failed", false);
1479
+ break;
1480
+ default:
1481
+ break;
742
1482
  }
743
1483
  }
744
1484
 
745
- function handleSearchResult(msg) {
746
- if (msg.requestId !== activeRequestId) return;
747
- currentResults = msg.results || [];
748
- currentAnswer = msg.answer || null;
749
- searchPending = false;
750
- maybeFinalizeQuery();
1485
+ function onFatal(message) {
1486
+ setWorkerState("dead");
1487
+ searchable = false;
1488
+ showStatus(false);
1489
+ showInitError(message || "The search engine crashed.", true);
751
1490
  }
752
1491
 
753
- function maybeFinalizeQuery() {
754
- if (searchPending) {
755
- return;
1492
+ function handleStatus(msg) {
1493
+ switch (msg.state) {
1494
+ case "loading_wasm":
1495
+ setWorkerState("loading");
1496
+ setStatus("Loading search engine…", null);
1497
+ announce("Loading search engine");
1498
+ break;
1499
+ case "loading_index":
1500
+ setStatus("Loading index…", msg.progress);
1501
+ break;
1502
+ case "index_ready":
1503
+ manifestInfo = msg.manifest || null;
1504
+ setWorkerState("index_ready");
1505
+ searchable = true;
1506
+ setStatus("Loading search model…", null);
1507
+ rerunQuery();
1508
+ break;
1509
+ case "consent_required":
1510
+ setWorkerState("awaiting_consent");
1511
+ showStatus(false);
1512
+ if (consentDeclined) break;
1513
+ showConsent(msg);
1514
+ break;
1515
+ case "downloading_model": {
1516
+ const pct = msg.progress == null ? null : Math.round(msg.progress * 100);
1517
+ const file = msg.file || "model";
1518
+ setStatus(`Downloading ${file}…` + (pct == null ? "" : ` ${pct}%`), msg.progress);
1519
+ if (pct != null) {
1520
+ const quarter = Math.floor(pct / 25);
1521
+ if (quarter !== lastAnnouncedQuarter && pct < 100) {
1522
+ lastAnnouncedQuarter = quarter;
1523
+ announce(`Downloading model, ${pct}%`);
1524
+ }
1525
+ }
1526
+ break;
1527
+ }
1528
+ case "loading_model":
1529
+ setStatus(msg.file === "transformers.js" ? "Loading WebGPU runtime…" : "Loading model…", null);
1530
+ break;
1531
+ case "error":
1532
+ setWorkerState(msg.fatal ? "dead" : "error");
1533
+ showStatus(false);
1534
+ showInitError(msg.message || "Failed to initialise search", !msg.unsupported);
1535
+ break;
1536
+ default:
1537
+ break;
756
1538
  }
1539
+ }
757
1540
 
758
- selectedIndex = -1;
1541
+ function handleReady(msg) {
1542
+ setWorkerState("ready");
1543
+ searchable = true;
1544
+ manifestInfo = msg.manifest || manifestInfo;
1545
+ const arms = msg.arms || {};
1546
+ host.dataset.arms = Object.keys(arms).filter((k) => arms[k]).join(",");
1547
+ host.dataset.lane = msg.lane || "";
1548
+ host.dataset.runtime = msg.runtime || "";
759
1549
  showStatus(false);
760
- renderResults();
1550
+ hide(consentCard);
1551
+ lastDegradedNotice = lib.degradedNotice(msg.arms, msg.degraded);
1552
+ announce(lastDegradedNotice ? "Search ready. " + lastDegradedNotice : "Search ready");
1553
+ rerunQuery();
761
1554
  }
762
1555
 
763
- function handleError(msg) {
764
- if (msg.requestId && msg.requestId !== activeRequestId) return;
765
- showError(msg.error || "Search failed");
1556
+ function rerunQuery() {
1557
+ const q = input.value.trim();
1558
+ if (isOpen && q.length >= 2) doSearch(q, false);
766
1559
  }
767
1560
 
768
- function doSearch(query) {
769
- if (!worker || engineState !== "ready") return;
770
- searchRequestId += 1;
771
- activeRequestId = searchRequestId;
772
- currentResults = [];
773
- currentAnswer = null;
774
- const answerMode = shouldUseAnswerMode(query);
775
- searchPending = true;
1561
+ // -- Consent --
1562
+ function showConsent(msg) {
1563
+ const lane = msg.lane || {};
1564
+ const model = lane.model || lane.repo || "the search model";
1565
+ const short = String(model).split("/").pop();
1566
+ consentText.textContent = lib.consentCopy({
1567
+ sizeBytes: msg.sizeBytes,
1568
+ model: short,
1569
+ saveData: saveData || !!msg.saveData,
1570
+ consentText: config.consentText,
1571
+ });
1572
+ consentAccept.textContent = msg.sizeBytes == null ? "Download" : `Download ${lib.formatBytes(msg.sizeBytes)}`;
1573
+ show(consentCard);
1574
+ announce("Semantic search needs a model download. " + consentText.textContent);
1575
+ }
776
1576
 
777
- if (answerMode) {
778
- statusText.textContent = "Searching and grounding answer...";
779
- progressBar.classList.add("sa-progress-indeterminate");
780
- progressFill.style.width = "";
781
- showStatus(true);
782
- }
1577
+ function acceptConsent() {
1578
+ hide(consentCard);
1579
+ if (!worker) return;
1580
+ setWorkerState("loading");
1581
+ worker.postMessage({ type: "init", indexUrl, baseUrl, version, denseRuntime: config.denseRuntime, consent: true });
1582
+ input.focus();
1583
+ }
783
1584
 
1585
+ function declineConsent() {
1586
+ consentDeclined = true;
1587
+ hide(consentCard);
1588
+ lastDegradedNotice = "Keyword-only results: the semantic model wasn't downloaded.";
1589
+ renderNotice();
1590
+ input.focus();
1591
+ }
1592
+
1593
+ // -- Searching --
1594
+ function looksFactualQuery(query) {
1595
+ const q = query.toLowerCase().trim();
1596
+ if (!q) return false;
1597
+ if (q.includes("?")) return true;
1598
+ if (/^(who|what|when|where|why|how|does|do|is|are|can|could|should)\b/.test(q)) return true;
1599
+ return q.split(/\s+/).length >= 5;
1600
+ }
1601
+
1602
+ function wantQa(query) {
1603
+ if (config.qaMode === "off") return false;
1604
+ if (!manifestInfo || !Array.isArray(manifestInfo.sections) || !manifestInfo.sections.includes("qa")) return false;
1605
+ if (config.qaMode === "always") return true;
1606
+ return looksFactualQuery(query);
1607
+ }
1608
+
1609
+ async function doSearch(query, explicit) {
1610
+ if (!searchable || !worker) return;
1611
+ // A different query cancels a running answer; re-searching the question
1612
+ // being answered (a late debounce, a re-run after init) does not.
1613
+ if (agentRun && !agentRun.done && query !== agentRun.question) abortAgent("new search");
1614
+ lastSearchedQuery = query;
1615
+ const requestId = ++requestSeq;
1616
+ activeSearchId = requestId;
1617
+ pending.set(requestId, {
1618
+ resolve: (msg) => {
1619
+ if (requestId !== activeSearchId) return;
1620
+ renderSearchResult(msg, query);
1621
+ },
1622
+ reject: (err) => {
1623
+ if (requestId !== activeSearchId) return;
1624
+ showSearchError(err.message || "Search failed");
1625
+ },
1626
+ });
784
1627
  worker.postMessage({
785
1628
  type: "search",
786
- requestId: activeRequestId,
787
- query: query,
788
- topK: config.resultTopK,
789
- answerTopK: config.answerTopK,
790
- answerMode: answerMode,
791
- qaSubject: config.qaSubject || "",
1629
+ requestId,
1630
+ query,
1631
+ topK: config.topK,
792
1632
  mode: "hybrid",
1633
+ qa: wantQa(query) ? Math.max(1, Math.min(config.answerTopK, 3)) : 0,
1634
+ explicit: !!explicit,
793
1635
  });
794
1636
  }
795
1637
 
796
- // -- Rendering --
797
- function renderResults() {
1638
+ function renderSearchResult(msg, query) {
1639
+ hide(errorEl);
1640
+ currentResults = msg.results || [];
1641
+ setSelected(-1);
1642
+ // A missing arm is only worth a notice once loading is over (or the
1643
+ // visitor declined the download); while the model loads the status bar
1644
+ // already says so.
1645
+ if (workerState === "ready") {
1646
+ lastDegradedNotice = lib.degradedNotice(msg.arms, msg.degraded);
1647
+ } else if (!consentDeclined) {
1648
+ lastDegradedNotice = null;
1649
+ }
1650
+ renderNotice();
1651
+ renderFaq(msg.qa);
1652
+ renderResults();
1653
+ const n = currentResults.length;
1654
+ announce(n === 0 ? `No results for ${query}` : `${n} result${n === 1 ? "" : "s"} for ${query}. Use the arrow keys to browse.`);
1655
+ }
1656
+
1657
+ function showSearchError(message) {
798
1658
  resultsList.textContent = "";
799
- errorEl.classList.remove("sa-visible");
800
- renderAnswer();
801
-
802
- if (currentResults.length === 0 && input.value.trim()) {
803
- const empty = document.createElement("div");
804
- empty.className = "sa-empty";
805
- empty.textContent = "No results found.";
806
- resultsList.appendChild(empty);
1659
+ hide(emptyEl);
1660
+ input.setAttribute("aria-expanded", "false");
1661
+ errorText.textContent = message;
1662
+ retryBtn.hidden = workerState !== "error" && workerState !== "dead";
1663
+ show(errorEl);
1664
+ }
1665
+
1666
+ function showInitError(message, canRetry) {
1667
+ errorText.textContent = message;
1668
+ retryBtn.hidden = !canRetry;
1669
+ show(errorEl);
1670
+ showStatus(false);
1671
+ }
1672
+
1673
+ function renderNotice() {
1674
+ if (lastDegradedNotice) {
1675
+ noticeEl.textContent = lastDegradedNotice;
1676
+ show(noticeEl);
1677
+ } else {
1678
+ hide(noticeEl);
1679
+ }
1680
+ }
1681
+
1682
+ function renderFaq(hits) {
1683
+ faqQ.textContent = "";
1684
+ faqA.textContent = "";
1685
+ faqSrc.textContent = "";
1686
+ if (!Array.isArray(hits) || hits.length === 0) {
1687
+ hide(faqCard);
807
1688
  return;
808
1689
  }
1690
+ const best = hits[0];
1691
+ if (!lib.faqPasses(best, config.qaMode)) {
1692
+ hide(faqCard);
1693
+ return;
1694
+ }
1695
+ faqQ.textContent = best.question;
1696
+ faqA.textContent = best.answer;
1697
+ if (best.source_url) {
1698
+ const a = el("a", "sa-cite", best.source_title || best.source_url);
1699
+ a.href = best.source_url;
1700
+ a.addEventListener("click", (e) => {
1701
+ e.preventDefault();
1702
+ navigateTo(best.source_url);
1703
+ });
1704
+ faqSrc.appendChild(document.createTextNode("Source: "));
1705
+ faqSrc.appendChild(a);
1706
+ }
1707
+ show(faqCard);
1708
+ }
809
1709
 
1710
+ function renderResults() {
1711
+ resultsList.textContent = "";
1712
+ if (currentResults.length === 0) {
1713
+ input.setAttribute("aria-expanded", "false");
1714
+ if (input.value.trim()) show(emptyEl);
1715
+ else hide(emptyEl);
1716
+ return;
1717
+ }
1718
+ hide(emptyEl);
1719
+ input.setAttribute("aria-expanded", "true");
810
1720
  currentResults.forEach((r, i) => {
811
- const li = document.createElement("a");
812
- li.className = "sa-result";
813
- li.href = r.url;
1721
+ const li = el("li", "sa-option");
1722
+ li.id = `sa-opt-${i}`;
814
1723
  li.setAttribute("role", "option");
815
1724
  li.setAttribute("aria-selected", i === selectedIndex ? "true" : "false");
816
1725
 
817
- const titleEl = document.createElement("div");
818
- titleEl.className = "sa-result-title";
819
- titleEl.textContent = r.title;
820
- li.appendChild(titleEl);
821
-
822
- const urlEl = document.createElement("div");
823
- urlEl.className = "sa-result-url";
824
- urlEl.textContent = r.url;
825
- li.appendChild(urlEl);
826
-
827
- if (
828
- r.section &&
829
- r.section !== "Semantic Segment" &&
830
- r.section !== "Summary Lane"
831
- ) {
832
- const sectionEl = document.createElement("div");
833
- sectionEl.className = "sa-result-section";
834
- sectionEl.textContent = r.section;
835
- li.appendChild(sectionEl);
1726
+ const a = el("a", "sa-result");
1727
+ a.href = r.url;
1728
+ a.tabIndex = -1;
1729
+ a.appendChild(el("div", "sa-result-title", r.title || r.url));
1730
+ a.appendChild(el("div", "sa-result-url", r.url));
1731
+ if (r.section && !HIDDEN_SECTIONS.test(String(r.section).trim())) {
1732
+ a.appendChild(el("div", "sa-result-section", r.section));
836
1733
  }
837
-
838
1734
  if (r.snippet) {
839
- const snippetEl = document.createElement("div");
840
- snippetEl.className = "sa-result-snippet";
841
- snippetEl.textContent = r.snippet;
842
- li.appendChild(snippetEl);
1735
+ a.appendChild(el("div", "sa-result-snippet", r.snippet));
843
1736
  }
844
-
845
- li.addEventListener("click", (e) => {
1737
+ a.addEventListener("click", (e) => {
846
1738
  e.preventDefault();
847
- navigateToResult(r);
1739
+ navigateTo(r.url);
848
1740
  });
849
-
1741
+ a.addEventListener("focus", () => setSelected(i));
1742
+ li.appendChild(a);
850
1743
  resultsList.appendChild(li);
851
1744
  });
1745
+ // Result links are Tab-reachable once rendered.
1746
+ resultsList.querySelectorAll(".sa-result").forEach((a) => {
1747
+ a.tabIndex = 0;
1748
+ });
1749
+ }
1750
+
1751
+ function setSelected(index) {
1752
+ selectedIndex = index;
1753
+ const items = resultsList.querySelectorAll(".sa-option");
1754
+ items.forEach((li, i) => li.setAttribute("aria-selected", i === selectedIndex ? "true" : "false"));
1755
+ if (selectedIndex >= 0 && items[selectedIndex]) {
1756
+ input.setAttribute("aria-activedescendant", items[selectedIndex].id);
1757
+ } else {
1758
+ input.removeAttribute("aria-activedescendant");
1759
+ }
852
1760
  }
853
1761
 
854
1762
  function moveSelection(delta) {
855
1763
  if (currentResults.length === 0) return;
856
-
857
- selectedIndex += delta;
858
- if (selectedIndex < 0) selectedIndex = currentResults.length - 1;
859
- if (selectedIndex >= currentResults.length) selectedIndex = 0;
860
-
861
- const items = resultsList.querySelectorAll(".sa-result");
862
- items.forEach((el, i) => {
863
- el.setAttribute("aria-selected", i === selectedIndex ? "true" : "false");
864
- });
865
-
866
- // Scroll selected into view
1764
+ let next = selectedIndex + delta;
1765
+ if (next < 0) next = currentResults.length - 1;
1766
+ if (next >= currentResults.length) next = 0;
1767
+ setSelected(next);
1768
+ const items = resultsList.querySelectorAll(".sa-option");
867
1769
  const selected = items[selectedIndex];
868
- if (selected) {
869
- selected.scrollIntoView({ block: "nearest" });
870
- }
1770
+ if (selected) selected.scrollIntoView({ block: "nearest" });
871
1771
  }
872
1772
 
873
- function navigateToResult(r) {
1773
+ function navigateTo(url) {
874
1774
  closeModal();
875
- window.location.href = r.url;
1775
+ window.location.href = url;
876
1776
  }
877
1777
 
878
1778
  function clearResults() {
1779
+ lastSearchedQuery = "";
879
1780
  currentResults = [];
880
- currentAnswer = null;
881
- selectedIndex = -1;
882
- searchPending = false;
1781
+ activeSearchId = 0;
1782
+ setSelected(-1);
883
1783
  resultsList.textContent = "";
884
- answerEl.classList.remove("sa-visible");
1784
+ hide(emptyEl);
1785
+ hide(faqCard);
1786
+ input.setAttribute("aria-expanded", "false");
885
1787
  }
886
1788
 
887
- function applyTriggerOffsets() {
888
- const baseInset = 24;
889
- const vertical = `${baseInset + config.offsetY}px`;
890
- const horizontal = `${baseInset + config.offsetX}px`;
1789
+ // -- Agent --
1790
+ async function detectAgent() {
1791
+ if (agentInfo !== null) return agentInfo;
1792
+ agentInfo = false;
1793
+ if (config.agentMode === "off" || !navigator.gpu || typeof navigator.gpu.requestAdapter !== "function") {
1794
+ return agentInfo;
1795
+ }
1796
+ try {
1797
+ const adapter = await navigator.gpu.requestAdapter();
1798
+ if (adapter) {
1799
+ agentInfo = {
1800
+ maxBufferSize: adapter.limits ? adapter.limits.maxBufferSize : 0,
1801
+ hasF16: !!(adapter.features && adapter.features.has("shader-f16")),
1802
+ };
1803
+ }
1804
+ } catch (err) {
1805
+ console.warn("eddie: WebGPU adapter probe failed", err);
1806
+ }
1807
+ if (agentInfo) {
1808
+ agentModel = lib.selectAgentModel({
1809
+ mode: config.agentModel,
1810
+ maxBufferSize: agentInfo.maxBufferSize,
1811
+ isMobile: lib.isMobileDevice(navigator),
1812
+ hasF16: agentInfo.hasF16,
1813
+ });
1814
+ askBtn.hidden = false;
1815
+ askHint.hidden = false;
1816
+ }
1817
+ return agentInfo;
1818
+ }
891
1819
 
892
- trigger.style.top = "";
893
- trigger.style.bottom = "";
894
- trigger.style.left = "";
895
- trigger.style.right = "";
1820
+ function agentConsentGiven() {
1821
+ try {
1822
+ return localStorage.getItem(AGENT_CONSENT_KEY) === agentModel.id;
1823
+ } catch (_) {
1824
+ return false;
1825
+ }
1826
+ }
896
1827
 
897
- switch (config.position) {
898
- case "top-left":
899
- trigger.style.top = vertical;
900
- trigger.style.left = horizontal;
1828
+ function rememberAgentConsent() {
1829
+ try {
1830
+ localStorage.setItem(AGENT_CONSENT_KEY, agentModel.id);
1831
+ } catch (_) {
1832
+ // storage unavailable: ask again next time
1833
+ }
1834
+ }
1835
+
1836
+ function resetAnswerCard() {
1837
+ answerProgress.textContent = "";
1838
+ answerText.textContent = "";
1839
+ answerText.classList.remove("sa-nohit");
1840
+ answerSources.textContent = "";
1841
+ clearAnswerActions();
1842
+ answerModel.textContent = agentModel ? `${agentModel.base} · runs in your browser` : "";
1843
+ hide(answerText);
1844
+ hide(answerSources);
1845
+ hide(answerProgress);
1846
+ }
1847
+
1848
+ /** Remove the answer card's buttons; if one of them had focus, return it to the input. */
1849
+ function clearAnswerActions() {
1850
+ const active = shadow.activeElement;
1851
+ answerActions.textContent = "";
1852
+ if (active && !active.isConnected) input.focus();
1853
+ }
1854
+
1855
+ function hideAnswerCard() {
1856
+ hide(answerCard);
1857
+ input.focus();
1858
+ }
1859
+
1860
+ function showAnswerError(message, question) {
1861
+ resetAnswerCard();
1862
+ answerProgress.textContent = "Couldn't generate an answer" + (message ? ` (${message})` : "") + ".";
1863
+ answerProgress.style.color = "var(--sa-error)";
1864
+ show(answerProgress);
1865
+ answerActions.appendChild(answerRetryBtn);
1866
+ answerActions.appendChild(agentConsentCancel);
1867
+ answerRetryBtn.onclick = () => askQuestion(question);
1868
+ agentConsentCancel.onclick = hideAnswerCard;
1869
+ show(answerCard);
1870
+ announce("Couldn't generate an answer.");
1871
+ }
1872
+
1873
+ async function askQuestion(question) {
1874
+ if (!question) {
1875
+ input.focus();
1876
+ return;
1877
+ }
1878
+ if (!(await detectAgent())) return;
1879
+ if (agentRun && !agentRun.done) abortAgent("new question");
1880
+ resetAnswerCard();
1881
+ answerProgress.style.color = "";
1882
+ show(answerCard);
1883
+
1884
+ if (saveData) {
1885
+ answerProgress.textContent = `Answers are off while Data Saver is on: the answer model is a ${lib.formatBytes(agentModel.sizeBytes)} download.`;
1886
+ show(answerProgress);
1887
+ agentConsentCancel.onclick = hideAnswerCard;
1888
+ answerActions.appendChild(agentConsentCancel);
1889
+ return;
1890
+ }
1891
+
1892
+ if (!agentLoaded && !agentConsentGiven()) {
1893
+ const consented = await requestAgentConsent();
1894
+ if (!consented) return;
1895
+ }
1896
+
1897
+ const run = { requestId: ++requestSeq, question, text: "", aborted: false, done: false, evidence: [], started: performance.now() };
1898
+ agentRun = run;
1899
+ answerProgress.textContent = "";
1900
+ show(answerProgress);
1901
+ answerActions.appendChild(stopBtn);
1902
+ try {
1903
+ await loadAgent(run);
1904
+ if (run.aborted) return;
1905
+ answerProgress.textContent = "Planning…";
1906
+ const plan = await agentCall("plan", { requestId: run.requestId, question, site: siteName });
1907
+ if (run.aborted) return;
1908
+ const queries = Array.from(new Set((plan.queries || []).concat([question])));
1909
+ answerProgress.textContent = "Searching: " + queries.join(" · ");
1910
+ const lists = await Promise.all(
1911
+ queries.map((q) => callWorker("search", { query: q, topK: 6, mode: "hybrid", evidence: true }).then((m) => m.results || []).catch(() => []))
1912
+ );
1913
+ if (run.aborted) return;
1914
+ const evidence = lib.mergeEvidence(lists, 6);
1915
+ await expandShortEvidence(evidence);
1916
+ if (run.aborted) return;
1917
+ // Confident FAQ entries (build-time QA lane) go first: they are short,
1918
+ // direct, and already carry a source page.
1919
+ const faqHits = await callWorker("qa", { query: question, k: 3 }).then((m) => m.hits || []).catch(() => []);
1920
+ if (run.aborted) return;
1921
+ const faqItems = lib.qaEvidence(faqHits, 2);
1922
+ run.evidence = faqItems.concat(evidence.map((r) => ({ title: r.title, url: r.url, text: r.text || r.snippet || "" })));
1923
+ answerProgress.textContent = "Answering…";
1924
+ await streamAnswer(run);
1925
+ } catch (err) {
1926
+ if (run.aborted) return;
1927
+ console.warn("eddie agent:", err);
1928
+ showAnswerError(err && err.message ? err.message : String(err), question);
1929
+ }
1930
+ }
1931
+
1932
+ function requestAgentConsent() {
1933
+ return new Promise((resolve) => {
1934
+ const size = lib.formatBytes(agentModel.sizeBytes);
1935
+ answerProgress.textContent = config.consentText
1936
+ ? config.consentText.replace(/\{size\}/g, size).replace(/\{model\}/g, agentModel.base)
1937
+ : `Answers come from ${agentModel.base}, a language model that runs in your browser. Download it once (about ${size})? It stays in your browser's cache.`;
1938
+ show(answerProgress);
1939
+ clearAnswerActions();
1940
+ agentConsentAccept.textContent = `Download ${size} and answer`;
1941
+ agentConsentAccept.onclick = () => {
1942
+ rememberAgentConsent();
1943
+ clearAnswerActions();
1944
+ resolve(true);
1945
+ };
1946
+ agentConsentCancel.onclick = () => {
1947
+ hideAnswerCard();
1948
+ resolve(false);
1949
+ };
1950
+ answerActions.appendChild(agentConsentAccept);
1951
+ answerActions.appendChild(agentConsentCancel);
1952
+ announce(answerProgress.textContent);
1953
+ agentConsentAccept.focus();
1954
+ });
1955
+ }
1956
+
1957
+ function ensureAgentWorker() {
1958
+ if (agentWorker) return;
1959
+ agentWorker = new Worker(lib.assetUrl(baseUrl, "eddie-agent-worker.js", version), { type: "module" });
1960
+ agentWorker.onmessage = (e) => onAgentMessage(e.data || {});
1961
+ agentWorker.onerror = (e) => {
1962
+ const message = e && e.message ? e.message : "agent worker failed to load";
1963
+ if (agentPendingLoad) {
1964
+ agentPendingLoad.reject(new Error(message));
1965
+ agentPendingLoad = null;
1966
+ }
1967
+ if (agentRun && !agentRun.done) {
1968
+ agentRun.done = true;
1969
+ showAnswerError(message, agentRun.question);
1970
+ }
1971
+ };
1972
+ }
1973
+
1974
+ function loadAgent(run) {
1975
+ if (agentLoaded) return Promise.resolve();
1976
+ if (agentLoading) return agentLoading;
1977
+ ensureAgentWorker();
1978
+ agentLoading = new Promise((resolve, reject) => {
1979
+ agentPendingLoad = { resolve, reject };
1980
+ answerProgress.textContent = `Loading ${agentModel.base}…`;
1981
+ announce(`Loading the answer model, ${agentModel.base}`);
1982
+ agentWorker.postMessage({ type: "load", model: agentModel.id });
1983
+ }).finally(() => {
1984
+ agentLoading = null;
1985
+ });
1986
+ return agentLoading;
1987
+ }
1988
+
1989
+ const agentPending = new Map();
1990
+ function agentCall(type, payload) {
1991
+ return new Promise((resolve, reject) => {
1992
+ agentPending.set(payload.requestId, { resolve, reject });
1993
+ agentWorker.postMessage(Object.assign({ type }, payload));
1994
+ });
1995
+ }
1996
+
1997
+ function streamAnswer(run) {
1998
+ return new Promise((resolve, reject) => {
1999
+ run.resolveStream = resolve;
2000
+ run.rejectStream = reject;
2001
+ run.text = "";
2002
+ run.lastPaint = 0;
2003
+ answerText.textContent = "";
2004
+ show(answerText);
2005
+ const caret = el("span", "sa-caret");
2006
+ caret.setAttribute("aria-hidden", "true");
2007
+ run.caret = caret;
2008
+ answerText.appendChild(caret);
2009
+ agentWorker.postMessage({ type: "ask", requestId: run.requestId, question: run.question, site: siteName, evidence: run.evidence });
2010
+ });
2011
+ }
2012
+
2013
+ function paintStream(run, force) {
2014
+ const now = performance.now();
2015
+ if (!force && reducedMotion && now - run.lastPaint < 600) return;
2016
+ run.lastPaint = now;
2017
+ answerText.textContent = lib.visibleStreamText(run.text);
2018
+ if (!run.done && run.caret) answerText.appendChild(run.caret);
2019
+ }
2020
+
2021
+ function onAgentMessage(msg) {
2022
+ if (msg.type !== "token" && msg.type !== "progress") {
2023
+ console.debug("eddie agent event " + msg.type + " " + (msg.requestId == null ? "" : msg.requestId) + (msg.message ? " " + msg.message : ""));
2024
+ }
2025
+ switch (msg.type) {
2026
+ case "progress":
2027
+ if (agentRun && !agentRun.done) {
2028
+ const pct = typeof msg.progress === "number" ? Math.round(msg.progress * 100) : null;
2029
+ answerProgress.textContent = `Loading ${agentModel.base}…` + (pct != null ? ` ${pct}%` : "");
2030
+ if (pct != null && pct % 25 === 0) announce(`Loading the answer model, ${pct}%`);
2031
+ }
901
2032
  break;
902
- case "top-right":
903
- trigger.style.top = vertical;
904
- trigger.style.right = horizontal;
2033
+ case "loaded":
2034
+ agentLoaded = true;
2035
+ if (agentPendingLoad) {
2036
+ agentPendingLoad.resolve(msg);
2037
+ agentPendingLoad = null;
2038
+ }
905
2039
  break;
906
- case "bottom-left":
907
- trigger.style.bottom = vertical;
908
- trigger.style.left = horizontal;
2040
+ case "plan_result":
2041
+ resolveAgent(msg);
2042
+ break;
2043
+ case "token":
2044
+ if (agentRun && msg.requestId === agentRun.requestId && !agentRun.done) {
2045
+ agentRun.text += msg.text;
2046
+ paintStream(agentRun, false);
2047
+ }
2048
+ break;
2049
+ case "done":
2050
+ if (agentRun && msg.requestId === agentRun.requestId) finishAnswer(agentRun, msg);
2051
+ break;
2052
+ case "aborted":
2053
+ if (agentRun && msg.requestId === agentRun.requestId) {
2054
+ agentRun.done = true;
2055
+ if (agentRun.resolveStream) agentRun.resolveStream();
2056
+ }
2057
+ break;
2058
+ case "error":
2059
+ if (msg.requestId != null && agentPending.has(msg.requestId)) {
2060
+ const p = agentPending.get(msg.requestId);
2061
+ agentPending.delete(msg.requestId);
2062
+ p.reject(new Error(msg.message || "agent request failed"));
2063
+ } else if (agentPendingLoad) {
2064
+ agentPendingLoad.reject(new Error(msg.message || "model load failed"));
2065
+ agentPendingLoad = null;
2066
+ } else if (agentRun && !agentRun.done && (msg.requestId == null || msg.requestId === agentRun.requestId)) {
2067
+ agentRun.done = true;
2068
+ if (agentRun.rejectStream) agentRun.rejectStream(new Error(msg.message || "answer failed"));
2069
+ }
909
2070
  break;
910
2071
  default:
911
- trigger.style.bottom = vertical;
912
- trigger.style.right = horizontal;
913
2072
  break;
914
2073
  }
915
2074
  }
916
2075
 
2076
+ function resolveAgent(msg) {
2077
+ const p = agentPending.get(msg.requestId);
2078
+ if (!p) return;
2079
+ agentPending.delete(msg.requestId);
2080
+ p.resolve(msg);
2081
+ }
2082
+
2083
+ function finishAnswer(run, msg) {
2084
+ run.done = true;
2085
+ clearAnswerActions();
2086
+ hide(answerProgress);
2087
+ answerText.textContent = "";
2088
+ if (msg.nohit) {
2089
+ answerText.classList.add("sa-nohit");
2090
+ answerText.textContent = msg.answer || lib.NOHIT;
2091
+ hide(answerSources);
2092
+ } else {
2093
+ renderAnswerText(msg.answer, msg.citations);
2094
+ answerSources.textContent = "";
2095
+ (msg.citations || []).forEach((c) => {
2096
+ const li = el("li");
2097
+ li.appendChild(el("span", "sa-cite-n", `[${c.n}]`));
2098
+ const a = el("a", "sa-cite", c.title || c.url);
2099
+ a.href = c.url;
2100
+ a.addEventListener("click", (e) => {
2101
+ e.preventDefault();
2102
+ navigateTo(c.url);
2103
+ });
2104
+ li.appendChild(a);
2105
+ answerSources.appendChild(li);
2106
+ });
2107
+ if (msg.citations && msg.citations.length) show(answerSources);
2108
+ else hide(answerSources);
2109
+ }
2110
+ show(answerText);
2111
+ const usage = msg.usage || {};
2112
+ run.usage = usage;
2113
+ announce(msg.nohit ? "No answer: the site doesn't cover that." : "Answer ready. " + msg.answer);
2114
+ if (run.resolveStream) run.resolveStream();
2115
+ console.info("eddie agent " + JSON.stringify({
2116
+ question: run.question,
2117
+ model: agentModel ? agentModel.id : null,
2118
+ evidence: run.evidence.length,
2119
+ citations: (msg.citations || []).length,
2120
+ nohit: !!msg.nohit,
2121
+ ttftMs: usage.ttftMs,
2122
+ totalMs: usage.totalMs,
2123
+ tps: usage.tps,
2124
+ sinceAskMs: Math.round(performance.now() - run.started),
2125
+ }));
2126
+ }
2127
+
2128
+ function renderAnswerText(text, citations) {
2129
+ const byN = new Map((citations || []).map((c) => [c.n, c]));
2130
+ const parts = String(text || "").split(/(\[\d+\])/g);
2131
+ parts.forEach((part) => {
2132
+ const m = /^\[(\d+)\]$/.exec(part);
2133
+ const c = m ? byN.get(Number(m[1])) : null;
2134
+ if (c) {
2135
+ const a = el("a", "sa-answer-cite", part);
2136
+ a.href = c.url;
2137
+ a.setAttribute("aria-label", `Source ${c.n}: ${c.title || c.url}`);
2138
+ a.addEventListener("click", (e) => {
2139
+ e.preventDefault();
2140
+ navigateTo(c.url);
2141
+ });
2142
+ answerText.appendChild(a);
2143
+ } else if (part) {
2144
+ answerText.appendChild(document.createTextNode(part));
2145
+ }
2146
+ });
2147
+ }
2148
+
2149
+ function abortAgent(reason) {
2150
+ const run = agentRun;
2151
+ if (!run || run.done) return;
2152
+ console.debug("eddie agent abort " + reason + " " + run.requestId);
2153
+ run.aborted = true;
2154
+ run.done = true;
2155
+ if (agentWorker) agentWorker.postMessage({ type: "abort", requestId: run.requestId });
2156
+ agentPending.forEach((p) => p.reject(new Error("aborted")));
2157
+ agentPending.clear();
2158
+ if (run.resolveStream) run.resolveStream();
2159
+ if (reason === "stopped") {
2160
+ paintStream(run, true);
2161
+ clearAnswerActions();
2162
+ answerProgress.textContent = "Stopped.";
2163
+ show(answerProgress);
2164
+ } else {
2165
+ hide(answerCard);
2166
+ }
2167
+ }
2168
+
2169
+ async function expandShortEvidence(items) {
2170
+ for (const item of items) {
2171
+ if ((item.text || "").length >= 200 || !item.url) continue;
2172
+ try {
2173
+ const res = await callWorker("page", { url: item.url });
2174
+ const chunks = (res.page && res.page.chunks) || [];
2175
+ const i = chunks.findIndex((c) => c.id === item.chunk);
2176
+ if (i < 0) continue;
2177
+ const parts = [];
2178
+ for (let j = Math.max(0, i - 1); j <= Math.min(chunks.length - 1, i + 1); j++) {
2179
+ if (chunks[j].text) parts.push(chunks[j].text);
2180
+ }
2181
+ item.text = parts.join(" ");
2182
+ } catch (_) {
2183
+ // keep the snippet
2184
+ }
2185
+ }
2186
+ }
2187
+
917
2188
  // -- Modal open/close --
918
2189
  function openModal() {
919
2190
  isOpen = true;
@@ -921,21 +2192,53 @@
921
2192
  backdrop.classList.add("sa-open");
922
2193
  trigger.style.display = "none";
923
2194
  input.value = "";
2195
+ liveRegion.textContent = "";
924
2196
  clearResults();
2197
+ hide(answerCard);
925
2198
  ensureWorker();
926
- // Focus after animation frame so the browser paints first
2199
+ detectAgent();
927
2200
  requestAnimationFrame(() => input.focus());
928
2201
  }
929
2202
 
930
2203
  function closeModal() {
2204
+ if (!isOpen) return;
931
2205
  isOpen = false;
2206
+ clearTimeout(searchTimer);
2207
+ abortAgent("closed");
932
2208
  backdrop.classList.remove("sa-open");
933
2209
  trigger.style.display = "";
934
2210
  trigger.focus();
935
2211
  }
936
2212
 
2213
+ function applyTriggerOffsets() {
2214
+ const baseInset = 24;
2215
+ const vertical = `${baseInset + config.offsetY}px`;
2216
+ const horizontal = `${baseInset + config.offsetX}px`;
2217
+ trigger.style.top = "";
2218
+ trigger.style.bottom = "";
2219
+ trigger.style.left = "";
2220
+ trigger.style.right = "";
2221
+ switch (config.position) {
2222
+ case "top-left":
2223
+ trigger.style.top = vertical;
2224
+ trigger.style.left = horizontal;
2225
+ break;
2226
+ case "top-right":
2227
+ trigger.style.top = vertical;
2228
+ trigger.style.right = horizontal;
2229
+ break;
2230
+ case "bottom-left":
2231
+ trigger.style.bottom = vertical;
2232
+ trigger.style.left = horizontal;
2233
+ break;
2234
+ default:
2235
+ trigger.style.bottom = vertical;
2236
+ trigger.style.right = horizontal;
2237
+ break;
2238
+ }
2239
+ }
2240
+
937
2241
  function rotateHeartSprite() {
938
- if (HEART_SPRITES.length === 0) return;
939
2242
  let idx = Math.floor(Math.random() * HEART_SPRITES.length);
940
2243
  if (HEART_SPRITES.length > 1 && idx === lastHeartIndex) {
941
2244
  idx = (idx + 1) % HEART_SPRITES.length;
@@ -949,13 +2252,10 @@
949
2252
  const ctx = heart.getContext("2d");
950
2253
  if (!sprite || !ctx) return;
951
2254
  ctx.clearRect(0, 0, heart.width, heart.height);
952
-
953
2255
  for (let y = 0; y < sprite.length; y++) {
954
2256
  const row = sprite[y];
955
2257
  for (let x = 0; x < row.length; x++) {
956
- const key = row[x];
957
- if (key === ".") continue;
958
- const color = HEART_PALETTE[key];
2258
+ const color = HEART_PALETTE[row[x]];
959
2259
  if (!color) continue;
960
2260
  ctx.fillStyle = color;
961
2261
  ctx.fillRect(x, y, 1, 1);
@@ -963,81 +2263,65 @@
963
2263
  }
964
2264
  }
965
2265
 
966
- // -- Helpers --
967
- function showStatus(visible) {
968
- status.classList.toggle("sa-visible", visible);
2266
+ // -- Status helpers --
2267
+ function show(node) {
2268
+ node.classList.add("sa-visible");
969
2269
  }
970
2270
 
971
- function showError(message) {
972
- errorEl.textContent = message;
973
- errorEl.classList.add("sa-visible");
2271
+ function hide(node) {
2272
+ node.classList.remove("sa-visible");
974
2273
  }
975
2274
 
976
- function shouldUseAnswerMode(query) {
977
- if (config.qaMode === "off") return false;
978
- if (config.qaMode === "always") return true;
979
- return looksFactualQuery(query);
2275
+ function showStatus(visible) {
2276
+ status.classList.toggle("sa-visible", visible);
980
2277
  }
981
2278
 
982
- function looksFactualQuery(query) {
983
- const q = query.toLowerCase().trim();
984
- if (!q) return false;
985
- if (q.includes("?")) return true;
986
- if (/^(who|what|when|where|why|how|does|do|is|are|can|could|should)\b/i.test(q)) {
987
- return true;
2279
+ function setStatus(text, progress) {
2280
+ statusText.textContent = text;
2281
+ showStatus(true);
2282
+ if (progress == null) {
2283
+ progressBar.classList.add("sa-progress-indeterminate");
2284
+ progressBar.removeAttribute("aria-valuenow");
2285
+ progressBar.setAttribute("aria-busy", "true");
2286
+ progressFill.style.width = "";
2287
+ } else {
2288
+ const pct = Math.max(0, Math.min(100, Math.round(progress * 100)));
2289
+ progressBar.classList.remove("sa-progress-indeterminate");
2290
+ progressBar.setAttribute("aria-valuenow", String(pct));
2291
+ progressBar.removeAttribute("aria-busy");
2292
+ progressFill.style.width = pct + "%";
988
2293
  }
989
- return q.split(/\s+/).length >= 5;
990
2294
  }
991
2295
 
992
- function renderAnswer() {
993
- answerEl.textContent = "";
994
- if (!currentAnswer || !currentAnswer.text) {
995
- answerEl.classList.remove("sa-visible");
996
- return;
997
- }
998
-
999
- const label = document.createElement("div");
1000
- label.className = "sa-answer-label";
1001
- label.textContent = "Experimental Answer";
1002
- answerEl.appendChild(label);
1003
-
1004
- const text = document.createElement("div");
1005
- text.className = "sa-answer-text";
1006
- text.textContent = currentAnswer.text;
1007
- answerEl.appendChild(text);
1008
-
1009
- if (currentAnswer.citations && currentAnswer.citations.length > 0) {
1010
- const cites = document.createElement("div");
1011
- cites.className = "sa-answer-cites";
1012
- currentAnswer.citations.slice(0, 3).forEach((url) => {
1013
- const a = document.createElement("a");
1014
- a.className = "sa-answer-cite";
1015
- a.href = url;
1016
- a.textContent = "source";
1017
- a.addEventListener("click", (e) => {
1018
- e.preventDefault();
1019
- navigateToResult({ url: url });
1020
- });
1021
- cites.appendChild(a);
1022
- });
1023
- answerEl.appendChild(cites);
1024
- }
1025
-
1026
- answerEl.classList.add("sa-visible");
2296
+ let announceToggle = false;
2297
+ function announce(text) {
2298
+ announceToggle = !announceToggle;
2299
+ liveRegion.textContent = text + (announceToggle ? "" : "​");
1027
2300
  }
1028
2301
 
1029
2302
  // -- Global keyboard shortcut: Ctrl+K or Cmd+K --
2303
+ function isEditable(node) {
2304
+ if (!node || node.nodeType !== 1) return false;
2305
+ if (node.isContentEditable) return true;
2306
+ const tag = node.tagName;
2307
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
2308
+ }
2309
+
1030
2310
  document.addEventListener("keydown", (e) => {
1031
- if ((e.ctrlKey || e.metaKey) && e.key === "k") {
2311
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
2312
+ if (typeof e.key !== "string" || e.key.toLowerCase() !== "k") return;
2313
+ if (isOpen) {
1032
2314
  e.preventDefault();
1033
- if (isOpen) {
1034
- closeModal();
1035
- } else {
1036
- openModal();
1037
- }
2315
+ closeModal();
2316
+ return;
1038
2317
  }
2318
+ const target = e.composedPath ? e.composedPath()[0] : e.target;
2319
+ if (isEditable(target)) return;
2320
+ e.preventDefault();
2321
+ openModal();
1039
2322
  });
1040
2323
 
1041
2324
  // -- Mount --
1042
2325
  document.body.appendChild(host);
1043
2326
  })();
2327
+ })();