eddie-jekyll 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b25c316b5227f2d8ba9dcf8bcbf23b3ae3ea9a31916c98c9d301bdf482c535d
4
- data.tar.gz: eea298e14f549efa67b7befc5980058ff0f0510f7146a66d62b95e8df943c8b1
3
+ metadata.gz: 0fb05e7b2291be54f498bc2e2df6c8b75689e2fecd1e62f069b05712d78882f5
4
+ data.tar.gz: 402f2f4a6569e9117f7acf584ff4d6966c05cf3601ce2909c3960b489d3180fd
5
5
  SHA512:
6
- metadata.gz: 6e782ffb5bac0ccb716b23f947ae1ac8b349ea883a83017cc98dbecd01e35461ec8f4d6c0dba20578398a2851cdf4beb86942ed5853ad841ac24c165019deb9c
7
- data.tar.gz: b6054c718bdf4c9bf3255d79b405e2c185a08b685f303e7deff8009c503ad5b942a22915563cbb71706985e2592511d35236c5ec50c1616cc5f858b1ea095272
6
+ metadata.gz: 28c2cf42975d69ddaa552acb7ece03fe9a7f390d5eac410b35b49387c3f527f85c2b4a01e288f557b681b2a509e40b073deb6d174e5e6296547a0e9466683652
7
+ data.tar.gz: a6159ac2597c2f1e294ef466d6d1e663cb0f753728e1fa97047a6992987891d5f998cf85d9b5752f61e27bdcf49ca40f7e28e53eb994b0313aece4f36e98aa17
@@ -0,0 +1,536 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Generated by widget/build.sh from widget/src/eddie-agent-worker.js and widget/src/lib/*.js; edit those instead.
3
+ "use strict";
4
+ const EddieLib = {};
5
+ // SPDX-License-Identifier: GPL-3.0-only
6
+
7
+ // Agent helpers shared by the widget and the agent worker: model selection,
8
+ // prompts, plan parsing, evidence assembly and answer post-processing.
9
+ // Pure functions; no WebLLM, no DOM.
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";
22
+
23
+ const NOHIT = "The site doesn't cover that.";
24
+ const NOHIT_RE = /\bthe site (?:doesn['’]t|does not|didn['’]t|did not) cover (?:that|this|it)\.?/gi;
25
+
26
+ const AGENT_MODEL_SIZES = {
27
+ "Qwen3.5-0.8B": 0.4e9,
28
+ "Qwen3.5-2B": 1.2e9,
29
+ "Qwen3.5-4B": 2.3e9,
30
+ };
31
+ const TWO_GIB = 2 * 1024 * 1024 * 1024;
32
+
33
+ const PLAN_SCHEMA = {
34
+ type: "object",
35
+ properties: {
36
+ queries: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 },
37
+ },
38
+ required: ["queries"],
39
+ };
40
+
41
+ function planPrompt(site) {
42
+ 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.`;
43
+ }
44
+
45
+ function answerPrompt(site) {
46
+ 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.`;
47
+ }
48
+
49
+ /** Strip the WebLLM variant suffix to get the family name shown to visitors. */
50
+ function baseModelId(id) {
51
+ return String(id).replace(/-q\d+f(16|32)_\d+-MLC$/i, "");
52
+ }
53
+
54
+ function agentModelBytes(id) {
55
+ const base = baseModelId(id);
56
+ return Object.prototype.hasOwnProperty.call(AGENT_MODEL_SIZES, base) ? AGENT_MODEL_SIZES[base] : null;
57
+ }
58
+
59
+ /**
60
+ * Choose the WebLLM model id.
61
+ * opts: { mode: "auto"|"quality"|<id>, maxBufferSize, isMobile, hasF16 }
62
+ */
63
+ function selectAgentModel(opts) {
64
+ const o = opts || {};
65
+ const mode = (o.mode || "auto").trim();
66
+ const suffix = o.hasF16 ? "-q4f16_1-MLC" : "-q4f32_1-MLC";
67
+ let base;
68
+ if (mode === "auto") {
69
+ const big = Number(o.maxBufferSize) >= TWO_GIB && !o.isMobile;
70
+ base = big ? "Qwen3.5-2B" : "Qwen3.5-0.8B";
71
+ } else if (mode === "quality") {
72
+ base = "Qwen3.5-2B";
73
+ } else {
74
+ return { id: mode, base: baseModelId(mode), sizeBytes: agentModelBytes(mode), explicit: true };
75
+ }
76
+ const id = base + suffix;
77
+ return { id, base, sizeBytes: AGENT_MODEL_SIZES[base], explicit: false };
78
+ }
79
+
80
+ function isMobileDevice(nav) {
81
+ const n = nav || {};
82
+ if (n.userAgentData && typeof n.userAgentData.mobile === "boolean") {
83
+ return n.userAgentData.mobile;
84
+ }
85
+ return /Mobi|Android|iPhone|iPad|iPod|Windows Phone/i.test(n.userAgent || "");
86
+ }
87
+
88
+ /** Remove <think>…</think> blocks; a dangling <think> loses only the tag. */
89
+ function stripThink(text) {
90
+ if (!text) return "";
91
+ let out = String(text).replace(/<think>[\s\S]*?<\/think>/g, "");
92
+ out = out.replace(/<think>/g, "").replace(/<\/think>/g, "");
93
+ return out.trim();
94
+ }
95
+
96
+ /**
97
+ * Display text for a partial stream: complete think blocks removed, and
98
+ * anything after an unclosed <think> hidden until it closes.
99
+ */
100
+ function visibleStreamText(partial) {
101
+ if (!partial) return "";
102
+ let out = String(partial).replace(/<think>[\s\S]*?<\/think>/g, "");
103
+ const open = out.indexOf("<think>");
104
+ if (open >= 0) out = out.substring(0, open);
105
+ return out.replace(/^\s+/, "");
106
+ }
107
+
108
+ function extractJsonObject(text) {
109
+ const s = String(text);
110
+ const start = s.indexOf("{");
111
+ const end = s.lastIndexOf("}");
112
+ if (start < 0 || end <= start) return null;
113
+ try {
114
+ return JSON.parse(s.substring(start, end + 1));
115
+ } catch (_) {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function cleanQuery(q) {
121
+ return String(q)
122
+ .replace(/\/?no_think/gi, "")
123
+ .replace(/[\s"'`*]+$/g, "")
124
+ .replace(/^[\s"'`*\-\d.)]+/g, "")
125
+ .replace(/\s+/g, " ")
126
+ .trim();
127
+ }
128
+
129
+ /** Parse the planner reply into 1..3 distinct queries; falls back to the question. */
130
+ function parsePlan(text, question) {
131
+ const cleaned = stripThink(text);
132
+ const obj = extractJsonObject(cleaned);
133
+ const raw = obj && Array.isArray(obj.queries) ? obj.queries : [];
134
+ const seen = new Set();
135
+ const out = [];
136
+ for (const item of raw) {
137
+ if (typeof item !== "string") continue;
138
+ const q = cleanQuery(item);
139
+ if (q.length < 2 || q.length > 80) continue;
140
+ const key = q.toLowerCase();
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(q);
144
+ if (out.length === 3) break;
145
+ }
146
+ if (out.length === 0 && question && question.trim()) {
147
+ out.push(question.trim());
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function urlKey(url) {
153
+ return String(url || "").replace(/#.*$/, "").replace(/\/+$/, "").toLowerCase();
154
+ }
155
+
156
+ /**
157
+ * Round-robin merge of several result lists, deduplicated by URL, at most
158
+ * `max` items. Each result keeps its own fields.
159
+ */
160
+ function mergeEvidence(lists, max) {
161
+ const limit = max == null ? 6 : max;
162
+ const seen = new Set();
163
+ const out = [];
164
+ const arrays = (lists || []).map((l) => (Array.isArray(l) ? l : []));
165
+ const longest = arrays.reduce((n, l) => Math.max(n, l.length), 0);
166
+ for (let i = 0; i < longest && out.length < limit; i++) {
167
+ for (const list of arrays) {
168
+ if (out.length >= limit) break;
169
+ const r = list[i];
170
+ if (!r || !r.url) continue;
171
+ const key = urlKey(r.url);
172
+ if (seen.has(key)) continue;
173
+ seen.add(key);
174
+ out.push(r);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** Cut to `max` characters at a word boundary, with an ellipsis. */
181
+ function truncateText(text, max) {
182
+ const limit = max == null ? 700 : max;
183
+ const s = String(text || "").replace(/\s+/g, " ").trim();
184
+ if (s.length <= limit) return s;
185
+ const cut = s.lastIndexOf(" ", limit - 1);
186
+ return s.substring(0, cut > limit * 0.6 ? cut : limit - 1).trim() + "…";
187
+ }
188
+
189
+ /** `[n] title (url)\ntext` blocks joined by blank lines. */
190
+ function formatEvidence(items, maxChars) {
191
+ return (items || [])
192
+ .map((e, i) => `[${i + 1}] ${e.title || e.url} (${e.url})\n${truncateText(e.text || e.snippet || "", maxChars)}`)
193
+ .join("\n\n");
194
+ }
195
+
196
+ function sourcesPrompt(items, question, maxChars) {
197
+ return `Sources:\n\n${formatEvidence(items, maxChars)}\n\nQuestion: ${question}`;
198
+ }
199
+
200
+ /**
201
+ * Post-process a raw model answer against the evidence list.
202
+ * Returns { answer, citations: [{n, url, title}], nohit }.
203
+ */
204
+ function postProcessAnswer(raw, evidence) {
205
+ const ev = Array.isArray(evidence) ? evidence : [];
206
+ let text = stripThink(raw);
207
+ // **[1]** -> [1]
208
+ text = text.replace(/\*\*\s*((?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+)\*\*/g, "$1");
209
+ // Lines that are only citation markers belong to the previous line.
210
+ const lines = text.split(/\r?\n/);
211
+ const merged = [];
212
+ for (const line of lines) {
213
+ const t = line.trim();
214
+ if (t && /^(?:\[\s*\d+(?:\s*,\s*\d+)*\s*\]\s*)+$/.test(t)) {
215
+ let j = merged.length - 1;
216
+ while (j >= 0 && merged[j].trim() === "") j--;
217
+ if (j >= 0) {
218
+ // Append only markers the previous line does not already carry.
219
+ const prev = merged[j];
220
+ const fresh = (t.match(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g) || []).filter((m) => !prev.includes(m.replace(/\s+/g, "")));
221
+ merged.length = j + 1;
222
+ if (fresh.length) merged[j] = prev.replace(/\s+$/, "") + " " + fresh.join("");
223
+ continue;
224
+ }
225
+ }
226
+ merged.push(line);
227
+ }
228
+ text = merged.join("\n");
229
+
230
+ // Drop the fallback sentence when anything else remains.
231
+ const withoutFallback = text.replace(NOHIT_RE, "").replace(/[ \t]+\n/g, "\n").trim();
232
+ const residue = withoutFallback
233
+ .replace(/\[\s*\d+(?:\s*,\s*\d+)*\s*\]/g, "")
234
+ .replace(/^\s*(?:yes|no)\b/i, "")
235
+ .replace(/[\s.,;:!?'"*-]+/g, "");
236
+ const onlyFallback = residue === "";
237
+ if (onlyFallback) {
238
+ return { answer: NOHIT, citations: [], nohit: true };
239
+ }
240
+ text = withoutFallback;
241
+
242
+ // Map [n] citations to evidence; drop out-of-range ones.
243
+ const cited = [];
244
+ const seen = new Set();
245
+ text = text.replace(/\[\s*(\d+(?:\s*,\s*\d+)*)\s*\]/g, (_, body) => {
246
+ const nums = body.split(",").map((x) => Number(x.trim()));
247
+ let out = "";
248
+ for (const n of nums) {
249
+ if (n >= 1 && n <= ev.length) {
250
+ out += `[${n}]`;
251
+ if (!seen.has(n)) {
252
+ seen.add(n);
253
+ cited.push(n);
254
+ }
255
+ }
256
+ }
257
+ return out;
258
+ });
259
+ // Collapse duplicate adjacent markers ("[1][1]") and tidy whitespace.
260
+ text = text.replace(/(\[\d+\])(?:\s*\1)+/g, "$1");
261
+ text = text.replace(/[ \t]{2,}/g, " ").replace(/ +([.,;:!?])/g, "$1").replace(/\n{3,}/g, "\n\n").trim();
262
+ const citations = cited
263
+ .sort((a, b) => a - b)
264
+ .map((n) => ({ n, url: ev[n - 1].url, title: ev[n - 1].title || ev[n - 1].url }));
265
+ return { answer: text, citations, nohit: text === "" };
266
+ }
267
+
268
+ return {
269
+ NOHIT,
270
+ AGENT_MODEL_SIZES,
271
+ PLAN_SCHEMA,
272
+ planPrompt,
273
+ answerPrompt,
274
+ sourcesPrompt,
275
+ baseModelId,
276
+ agentModelBytes,
277
+ selectAgentModel,
278
+ isMobileDevice,
279
+ stripThink,
280
+ visibleStreamText,
281
+ parsePlan,
282
+ mergeEvidence,
283
+ truncateText,
284
+ formatEvidence,
285
+ postProcessAnswer,
286
+ };
287
+ });
288
+
289
+ // SPDX-License-Identifier: GPL-3.0-only
290
+
291
+ // Eddie agent worker (module worker, created on the first "Ask").
292
+ //
293
+ // Runs WebLLM in the worker; retrieval stays in the widget, which owns the
294
+ // search worker and passes evidence in. widget/build.sh concatenates
295
+ // widget/src/lib/agent.js ahead of this file (EddieLib).
296
+ //
297
+ // Protocol (main thread -> worker):
298
+ // load {model}
299
+ // plan {requestId, question, site}
300
+ // ask {requestId, question, site, evidence: [{title, url, text}]}
301
+ // abort {requestId?}
302
+ // (worker -> main thread):
303
+ // progress {text, progress}
304
+ // loaded {model, loadMs}
305
+ // plan_result {requestId, queries, ms}
306
+ // token {requestId, text}
307
+ // done {requestId, answer, citations: [{n, url, title}], nohit, usage}
308
+ // aborted {requestId}
309
+ // error {requestId?, message}
310
+
311
+ "use strict";
312
+
313
+ const WEBLLM_URL = "https://esm.run/@mlc-ai/web-llm@0.2.84";
314
+ const EVIDENCE_CHARS = 700;
315
+
316
+ const lib = EddieLib;
317
+
318
+ let webllm = null;
319
+ let engine = null;
320
+ let modelId = null;
321
+ let loading = null;
322
+ let active = null; // { requestId, aborted }
323
+ let queue = Promise.resolve();
324
+
325
+ self.onmessage = function (e) {
326
+ const msg = e.data || {};
327
+ switch (msg.type) {
328
+ case "load":
329
+ load(msg);
330
+ break;
331
+ case "plan":
332
+ enqueue(() => plan(msg), msg.requestId);
333
+ break;
334
+ case "ask":
335
+ enqueue(() => ask(msg), msg.requestId);
336
+ break;
337
+ case "abort":
338
+ abort(msg);
339
+ break;
340
+ default:
341
+ postError(msg.requestId, `unknown message type ${String(msg.type)}`);
342
+ }
343
+ };
344
+
345
+ function enqueue(fn, requestId) {
346
+ queue = queue
347
+ .then(() => {
348
+ console.debug("eddie agent worker: start", requestId);
349
+ return fn();
350
+ })
351
+ .catch((err) => postError(requestId, describe(err)))
352
+ .then(() => console.debug("eddie agent worker: end", requestId));
353
+ }
354
+
355
+ async function load(msg) {
356
+ const model = String(msg.model || "");
357
+ if (!model) {
358
+ postError(undefined, "load: model is required");
359
+ return;
360
+ }
361
+ if (engine && modelId === model) {
362
+ self.postMessage({ type: "loaded", model, loadMs: 0, cached: true });
363
+ return;
364
+ }
365
+ if (loading) {
366
+ try {
367
+ await loading;
368
+ } catch (_) {
369
+ // fall through and try again
370
+ }
371
+ if (engine && modelId === model) {
372
+ self.postMessage({ type: "loaded", model, loadMs: 0, cached: true });
373
+ return;
374
+ }
375
+ }
376
+ loading = (async () => {
377
+ const t0 = performance.now();
378
+ if (!webllm) {
379
+ self.postMessage({ type: "progress", text: "Loading the WebLLM runtime…", progress: 0 });
380
+ webllm = await import(WEBLLM_URL);
381
+ }
382
+ if (engine) {
383
+ try {
384
+ await engine.unload();
385
+ } catch (_) {
386
+ // ignore
387
+ }
388
+ engine = null;
389
+ modelId = null;
390
+ }
391
+ const created = await webllm.CreateMLCEngine(model, {
392
+ initProgressCallback: (p) => {
393
+ self.postMessage({
394
+ type: "progress",
395
+ text: p && p.text ? p.text : "Loading model…",
396
+ progress: p && typeof p.progress === "number" ? p.progress : null,
397
+ });
398
+ },
399
+ });
400
+ engine = created;
401
+ modelId = model;
402
+ self.postMessage({ type: "loaded", model, loadMs: Math.round(performance.now() - t0) });
403
+ })();
404
+ try {
405
+ await loading;
406
+ } catch (err) {
407
+ engine = null;
408
+ modelId = null;
409
+ postError(undefined, describe(err));
410
+ } finally {
411
+ loading = null;
412
+ }
413
+ }
414
+
415
+ function requireEngine() {
416
+ if (!engine) throw new Error("model not loaded");
417
+ }
418
+
419
+ async function plan(msg) {
420
+ requireEngine();
421
+ const question = String(msg.question || "").trim();
422
+ const site = String(msg.site || "this website");
423
+ const t0 = performance.now();
424
+ const reply = await engine.chat.completions.create({
425
+ messages: [
426
+ { role: "system", content: lib.planPrompt(site) },
427
+ { role: "user", content: question },
428
+ ],
429
+ temperature: 0,
430
+ max_tokens: 100,
431
+ response_format: { type: "json_object", schema: JSON.stringify(lib.PLAN_SCHEMA) },
432
+ extra_body: { enable_thinking: false },
433
+ });
434
+ const content = reply && reply.choices && reply.choices[0] && reply.choices[0].message ? reply.choices[0].message.content : "";
435
+ const queries = lib.parsePlan(content, question);
436
+ self.postMessage({ type: "plan_result", requestId: msg.requestId, queries, ms: Math.round(performance.now() - t0) });
437
+ }
438
+
439
+ async function ask(msg) {
440
+ requireEngine();
441
+ const requestId = msg.requestId;
442
+ const question = String(msg.question || "").trim();
443
+ const site = String(msg.site || "this website");
444
+ const evidence = Array.isArray(msg.evidence) ? msg.evidence.filter((e) => e && e.url) : [];
445
+ if (evidence.length === 0) {
446
+ self.postMessage({
447
+ type: "done",
448
+ requestId,
449
+ answer: lib.NOHIT,
450
+ citations: [],
451
+ nohit: true,
452
+ raw: "",
453
+ usage: { ttftMs: 0, totalMs: 0, tps: null, completionTokens: 0 },
454
+ });
455
+ return;
456
+ }
457
+ active = { requestId, aborted: false };
458
+ const t0 = performance.now();
459
+ let first = 0;
460
+ let text = "";
461
+ let usage = null;
462
+ try {
463
+ const stream = await engine.chat.completions.create({
464
+ messages: [
465
+ { role: "system", content: lib.answerPrompt(site) },
466
+ { role: "user", content: lib.sourcesPrompt(evidence, question, EVIDENCE_CHARS) },
467
+ ],
468
+ stream: true,
469
+ stream_options: { include_usage: true },
470
+ temperature: 0,
471
+ frequency_penalty: 0.5,
472
+ presence_penalty: 0,
473
+ max_tokens: 220,
474
+ extra_body: { enable_thinking: false },
475
+ });
476
+ // Never break out of this loop: WebLLM releases its generation lock at
477
+ // the end of the async generator, and an early exit skips that release,
478
+ // hanging every later completion. After interruptGenerate() the stream
479
+ // ends by itself within one decode step; drop the tokens until then.
480
+ for await (const chunk of stream) {
481
+ if (active.aborted) continue;
482
+ const delta = chunk && chunk.choices && chunk.choices[0] && chunk.choices[0].delta ? chunk.choices[0].delta.content : null;
483
+ if (delta) {
484
+ if (!first) first = performance.now();
485
+ text += delta;
486
+ self.postMessage({ type: "token", requestId, text: delta });
487
+ }
488
+ if (chunk && chunk.usage) usage = chunk.usage;
489
+ }
490
+ } finally {
491
+ const wasAborted = active && active.aborted;
492
+ active = null;
493
+ if (wasAborted) {
494
+ self.postMessage({ type: "aborted", requestId });
495
+ return;
496
+ }
497
+ }
498
+ const processed = lib.postProcessAnswer(text, evidence);
499
+ const totalMs = Math.round(performance.now() - t0);
500
+ self.postMessage({
501
+ type: "done",
502
+ requestId,
503
+ answer: processed.answer,
504
+ citations: processed.citations,
505
+ nohit: processed.nohit,
506
+ raw: text,
507
+ usage: {
508
+ ttftMs: first ? Math.round(first - t0) : totalMs,
509
+ totalMs,
510
+ tps: usage && usage.extra && typeof usage.extra.decode_tokens_per_s === "number" ? Math.round(usage.extra.decode_tokens_per_s) : null,
511
+ completionTokens: usage ? usage.completion_tokens : null,
512
+ },
513
+ });
514
+ }
515
+
516
+ function abort(msg) {
517
+ console.debug("eddie agent worker: abort", msg.requestId, active ? active.requestId : null);
518
+ if (!active) return;
519
+ if (msg.requestId != null && msg.requestId !== active.requestId) return;
520
+ active.aborted = true;
521
+ try {
522
+ if (engine) engine.interruptGenerate();
523
+ } catch (err) {
524
+ console.warn("eddie agent: interrupt failed", err);
525
+ }
526
+ }
527
+
528
+ function postError(requestId, message) {
529
+ self.postMessage({ type: "error", requestId: requestId == null ? undefined : requestId, message });
530
+ }
531
+
532
+ function describe(err) {
533
+ if (err == null) return "unknown error";
534
+ if (typeof err === "string") return err;
535
+ return err.message || String(err);
536
+ }