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