eddie-jekyll 0.2.4 → 0.4.0

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