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