@dogsbay/plugin-ask 0.2.0-beta.101

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.
package/src/client.ts ADDED
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Client runtime for `@dogsbay/plugin-ask`.
3
+ *
4
+ * Renders a multi-turn "Ask AI" conversation in two targets that share one
5
+ * code path:
6
+ * - **Docked panel** — a server-rendered shell (`RightRailAsk.astro`,
7
+ * `[data-dba-panel="rail"]`) placed in DocsLayout's `right-rail` slot.
8
+ * - **Floating modal** — a body-level button + `<dialog>` built here; the
9
+ * fallback on rail-less layouts and the mobile entry when a panel exists.
10
+ *
11
+ * Each turn appends to a `.dba-thread`; with `conversational` on (default) the
12
+ * history is sent as context so follow-ups are understood. Astro view
13
+ * transitions swap the document, so we mount on `astro:page-load` and tear the
14
+ * floating widget down on `astro:before-swap` (the docked shell is re-wired).
15
+ */
16
+ // @ts-expect-error: virtual module resolved by the Dogsbay Vite alias at build.
17
+ import config from "virtual:dogsbay-plugin-config/@dogsbay/plugin-ask";
18
+ import {
19
+ buildAskRequest,
20
+ buildChatRequest,
21
+ buildFeedbackRequest,
22
+ trimHistory,
23
+ extractText,
24
+ parseSources,
25
+ shouldRetryTurnstile,
26
+ type ChatMessage,
27
+ type Source,
28
+ } from "./protocol.js";
29
+ import { renderMarkdown, escapeHtml } from "./md.js";
30
+ import type { AskMode, AskPlacement, AskPosition } from "./schema.js";
31
+
32
+ interface RuntimeConfig {
33
+ endpoint: string;
34
+ site: string;
35
+ mode: AskMode;
36
+ buttonLabel: string;
37
+ title: string;
38
+ placeholder: string;
39
+ position: AskPosition;
40
+ placement: AskPlacement;
41
+ conversational: boolean;
42
+ maxTurns: number;
43
+ persistConversation: boolean;
44
+ feedback: boolean;
45
+ linkBase: string;
46
+ turnstileSitekey: string;
47
+ }
48
+
49
+ const cfg = config as RuntimeConfig;
50
+
51
+ // Turnstile token (refreshed per ask). Populated by the widget callbacks.
52
+ let turnstileToken: string | null = null;
53
+ let turnstileScriptLoaded = false;
54
+
55
+ interface TurnstileApi {
56
+ reset?: (widget?: unknown) => void;
57
+ }
58
+ function turnstileApi(): TurnstileApi | undefined {
59
+ return (window as unknown as { turnstile?: TurnstileApi }).turnstile;
60
+ }
61
+
62
+ function loadTurnstile(): void {
63
+ if (turnstileScriptLoaded || !cfg.turnstileSitekey) return;
64
+ turnstileScriptLoaded = true;
65
+ const w = window as unknown as Record<string, unknown>;
66
+ w.__dbaTsCb = (t: string) => { turnstileToken = t; };
67
+ w.__dbaTsExpired = () => { turnstileToken = null; };
68
+ const s = document.createElement("script");
69
+ s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
70
+ s.async = true;
71
+ s.defer = true;
72
+ document.head.appendChild(s);
73
+ }
74
+
75
+ function sourcesHtml(sources: Source[]): string {
76
+ const items = sources
77
+ .map((s) =>
78
+ s.href
79
+ ? `<li><a href="${escapeHtml(s.href)}">${escapeHtml(s.label)}</a></li>`
80
+ : `<li>${escapeHtml(s.label)}</li>`,
81
+ )
82
+ .join("");
83
+ return `<div class="dba-sources"><div class="dba-sources-title">Sources</div><ul>${items}</ul></div>`;
84
+ }
85
+
86
+ // ─── Conversation persistence (sessionStorage; tab-scoped; OPT-IN) ───────────
87
+ // Persisting the reader's own conversation lets it survive a page refresh and
88
+ // same-window navigation (e.g. clicking a source link). It is OFF by default —
89
+ // nothing is stored until the reader opts in via "Remember conversation".
90
+ // Tab-scoped, cleared on tab close, never sent anywhere.
91
+
92
+ const STORE = "dba-ask";
93
+ const OPT_IN = "1";
94
+ const convoKey = () => `${STORE}:convo:${cfg.site}`;
95
+ const optInKey = () => `${STORE}:save:${cfg.site}`;
96
+
97
+ function isPersistMode(): boolean {
98
+ return cfg.conversational && cfg.mode !== "search" && cfg.persistConversation;
99
+ }
100
+ function persistOptedIn(): boolean {
101
+ try { return sessionStorage.getItem(optInKey()) === OPT_IN; } catch { return false; }
102
+ }
103
+ function persistEnabled(): boolean {
104
+ return isPersistMode() && persistOptedIn();
105
+ }
106
+ function loadStoredHistory(): ChatMessage[] {
107
+ if (!persistEnabled()) return [];
108
+ try {
109
+ const parsed = JSON.parse(sessionStorage.getItem(convoKey()) || "null");
110
+ if (!Array.isArray(parsed)) return [];
111
+ return parsed.filter(
112
+ (m): m is ChatMessage =>
113
+ m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string",
114
+ );
115
+ } catch { return []; }
116
+ }
117
+ function saveStoredHistory(history: ChatMessage[]): void {
118
+ if (!persistEnabled()) return;
119
+ try { sessionStorage.setItem(convoKey(), JSON.stringify(history)); } catch { /* storage off */ }
120
+ }
121
+ function clearStoredHistory(): void {
122
+ try { sessionStorage.removeItem(convoKey()); } catch { /* ignore */ }
123
+ }
124
+ function setPersistOptIn(optedIn: boolean): void {
125
+ try {
126
+ if (optedIn) sessionStorage.setItem(optInKey(), OPT_IN);
127
+ else { sessionStorage.removeItem(optInKey()); clearStoredHistory(); }
128
+ } catch { /* ignore */ }
129
+ }
130
+
131
+ /** POST a prebuilt request body with the current Turnstile token (if any). */
132
+ function postBody(body: object): Promise<Response> {
133
+ return fetch(cfg.endpoint, {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({ ...body, ...(turnstileToken ? { turnstileToken } : {}) }),
137
+ });
138
+ }
139
+
140
+ /** Resolve once a Turnstile token is available, or after `timeoutMs`. */
141
+ function waitForTurnstileToken(timeoutMs: number): Promise<string | null> {
142
+ if (turnstileToken) return Promise.resolve(turnstileToken);
143
+ return new Promise((resolve) => {
144
+ const start = Date.now();
145
+ const tick = () => {
146
+ if (turnstileToken) return resolve(turnstileToken);
147
+ if (Date.now() - start >= timeoutMs) return resolve(null);
148
+ setTimeout(tick, 150);
149
+ };
150
+ tick();
151
+ });
152
+ }
153
+
154
+ /**
155
+ * POST a thumbs vote for an answer, reusing the question Turnstile-retry path.
156
+ * Best-effort: failures never disrupt reading. Tokens are single-use, so this
157
+ * obtains a fresh one and resets afterwards.
158
+ */
159
+ async function sendFeedback(askId: string, rating: 1 | -1): Promise<void> {
160
+ const body = buildFeedbackRequest(askId, rating);
161
+ try {
162
+ loadTurnstile();
163
+ if (cfg.turnstileSitekey && !turnstileToken) await waitForTurnstileToken(4000);
164
+ let res = await postBody(body);
165
+ if (shouldRetryTurnstile(res.status, !!cfg.turnstileSitekey)) {
166
+ const token = await waitForTurnstileToken(4000);
167
+ if (token) res = await postBody(body);
168
+ }
169
+ } catch {
170
+ /* feedback is best-effort */
171
+ } finally {
172
+ turnstileApi()?.reset?.();
173
+ turnstileToken = null;
174
+ }
175
+ }
176
+
177
+ /** Render a 👍/👎 helpfulness control under a completed assistant answer. */
178
+ function appendFeedback(turn: HTMLElement, answer: HTMLDivElement): void {
179
+ if (!cfg.feedback) return;
180
+ const askId = answer.dataset.askId;
181
+ if (!askId) return; // no id to correlate (search mode / older proxy / restored)
182
+
183
+ const fb = document.createElement("div");
184
+ fb.className = "dba-feedback";
185
+ const q = document.createElement("span");
186
+ q.className = "dba-feedback-q";
187
+ q.textContent = "Was this helpful?";
188
+ const up = document.createElement("button");
189
+ up.type = "button";
190
+ up.className = "dba-fb dba-fb-up";
191
+ up.setAttribute("aria-label", "Helpful");
192
+ up.textContent = "👍";
193
+ const down = document.createElement("button");
194
+ down.type = "button";
195
+ down.className = "dba-fb dba-fb-down";
196
+ down.setAttribute("aria-label", "Not helpful");
197
+ down.textContent = "👎";
198
+
199
+ const thank = () => {
200
+ fb.textContent = "Thanks for the feedback.";
201
+ fb.classList.add("dba-feedback-done");
202
+ };
203
+ up.addEventListener("click", () => { void sendFeedback(askId, 1); thank(); });
204
+ down.addEventListener("click", () => { void sendFeedback(askId, -1); thank(); });
205
+ fb.append(q, up, down);
206
+ turn.appendChild(fb);
207
+ }
208
+
209
+ /** Nearest scrollable ancestor (the rail aside, or the dialog's thread). */
210
+ function scrollParent(el: HTMLElement): HTMLElement | null {
211
+ let p = el.parentElement;
212
+ while (p) {
213
+ const oy = getComputedStyle(p).overflowY;
214
+ if ((oy === "auto" || oy === "scroll") && p.scrollHeight > p.clientHeight) return p;
215
+ p = p.parentElement;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** Stream a proxy response into an answer element; returns the answer Markdown. */
221
+ async function streamInto(res: Response, answer: HTMLDivElement): Promise<string> {
222
+ answer.textContent = "";
223
+ answer.dataset.state = "streaming";
224
+ // Stamp the answer with the proxy's interaction id so a later thumbs vote can
225
+ // name this exact answer. Empty when absent (search mode / older proxy).
226
+ answer.dataset.askId = res.headers.get("X-Ask-Id") ?? "";
227
+
228
+ const sc = scrollParent(answer);
229
+ // Keep the newest content in view as it streams — but only if the reader is
230
+ // already at the bottom, so we don't yank them up while they re-read earlier.
231
+ const atBottom = () => !sc || sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 80;
232
+ const render = (html: string) => {
233
+ const stick = atBottom();
234
+ answer.innerHTML = html;
235
+ if (stick && sc) sc.scrollTop = sc.scrollHeight;
236
+ };
237
+
238
+ const reader = res.body!.getReader();
239
+ const decoder = new TextDecoder();
240
+ let buf = ""; // line buffer (don't parse a frame split across reads)
241
+ let rawAll = ""; // whole stream, for the trailing `event: chunks` sources
242
+ let answerMd = ""; // accumulated answer markdown
243
+ for (;;) {
244
+ const { value, done } = await reader.read();
245
+ if (done) break;
246
+ const text = decoder.decode(value, { stream: true });
247
+ rawAll += text;
248
+ buf += text;
249
+ const nl = buf.lastIndexOf("\n");
250
+ if (nl >= 0) {
251
+ answerMd += extractText(buf.slice(0, nl + 1));
252
+ buf = buf.slice(nl + 1);
253
+ render(renderMarkdown(answerMd));
254
+ }
255
+ }
256
+ if (buf) answerMd += extractText(buf);
257
+ const sources = parseSources(rawAll, cfg.linkBase);
258
+ render(renderMarkdown(answerMd) + (sources.length ? sourcesHtml(sources) : ""));
259
+ answer.dataset.state = "done";
260
+ return answerMd;
261
+ }
262
+
263
+ function appendUserTurn(thread: HTMLElement, text: string): void {
264
+ const turn = document.createElement("div");
265
+ turn.className = "dba-turn dba-turn-user";
266
+ turn.textContent = text; // textContent — no injection from the question
267
+ thread.appendChild(turn);
268
+ }
269
+
270
+ function appendAssistantTurn(thread: HTMLElement): HTMLDivElement {
271
+ const turn = document.createElement("div");
272
+ turn.className = "dba-turn dba-turn-assistant";
273
+ const answer = document.createElement("div");
274
+ answer.className = "dba-answer";
275
+ answer.setAttribute("aria-live", "polite");
276
+ answer.dataset.state = "loading";
277
+ answer.textContent = "…";
278
+ turn.appendChild(answer);
279
+ thread.appendChild(turn);
280
+ return answer;
281
+ }
282
+
283
+ interface ConvoEls {
284
+ form: HTMLFormElement;
285
+ input: HTMLInputElement;
286
+ submit: HTMLButtonElement;
287
+ thread: HTMLElement;
288
+ newChat?: HTMLButtonElement | null;
289
+ remember?: HTMLInputElement | null;
290
+ }
291
+
292
+ /** Re-render a stored conversation into the thread (no streaming). */
293
+ function restoreThread(thread: HTMLElement, history: ChatMessage[]): void {
294
+ let last: HTMLElement | null = null;
295
+ for (const m of history) {
296
+ if (m.role === "user") {
297
+ appendUserTurn(thread, m.content);
298
+ } else {
299
+ const a = appendAssistantTurn(thread);
300
+ a.dataset.state = "done";
301
+ a.innerHTML = renderMarkdown(m.content);
302
+ }
303
+ last = thread.lastElementChild as HTMLElement | null;
304
+ }
305
+ // Land at the bottom of the restored conversation (after a refresh / a
306
+ // same-window source-link navigation), once layout has settled.
307
+ if (last) {
308
+ requestAnimationFrame(() => {
309
+ const sc = scrollParent(last!);
310
+ if (sc) sc.scrollTop = sc.scrollHeight;
311
+ else last!.scrollIntoView({ block: "end" });
312
+ });
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Wire one conversation surface (docked panel or floating dialog). Each keeps
318
+ * its own history closure. Shares the stream + Turnstile-retry logic.
319
+ */
320
+ function wireConversation(els: ConvoEls): void {
321
+ // Restore a persisted conversation (refresh / same-window navigation).
322
+ const history: ChatMessage[] = loadStoredHistory();
323
+ if (history.length) restoreThread(els.thread, history);
324
+
325
+ // Reader opt-IN toggle: persistence is off until the reader checks it. On
326
+ // change, start persisting (saving the current conversation) or stop + clear.
327
+ if (els.remember) {
328
+ els.remember.checked = persistOptedIn();
329
+ els.remember.addEventListener("change", () => {
330
+ setPersistOptIn(els.remember!.checked);
331
+ if (els.remember!.checked) saveStoredHistory(history);
332
+ });
333
+ }
334
+
335
+ const reset = () => {
336
+ els.thread.innerHTML = "";
337
+ history.length = 0;
338
+ clearStoredHistory();
339
+ };
340
+ els.newChat?.addEventListener("click", () => {
341
+ reset();
342
+ els.input.focus();
343
+ });
344
+
345
+ els.form.addEventListener("submit", async (e) => {
346
+ e.preventDefault();
347
+ const query = els.input.value.trim();
348
+ if (!query) return;
349
+ els.input.value = "";
350
+
351
+ // Single-shot modes show one exchange at a time.
352
+ const conversational = cfg.conversational && cfg.mode !== "search";
353
+ if (!conversational) els.thread.innerHTML = "";
354
+
355
+ appendUserTurn(els.thread, query);
356
+ const answer = appendAssistantTurn(els.thread);
357
+ answer.scrollIntoView?.({ block: "nearest" });
358
+ els.submit.disabled = true;
359
+
360
+ // Build the request body. Conversational answer mode sends trimmed history;
361
+ // search and non-conversational answer stay single-shot.
362
+ let body: object;
363
+ if (cfg.mode === "search") {
364
+ body = buildAskRequest(cfg.site, query, "search");
365
+ } else if (conversational) {
366
+ history.push({ role: "user", content: query });
367
+ body = buildChatRequest(cfg.site, trimHistory(history, cfg.maxTurns));
368
+ } else {
369
+ body = buildAskRequest(cfg.site, query, "answer");
370
+ }
371
+
372
+ try {
373
+ let res = await postBody(body);
374
+ // Turnstile race: lazy-loaded on first interaction, so a fast submit can
375
+ // beat the token and the proxy 403s. Wait once for a fresh token + retry.
376
+ if (shouldRetryTurnstile(res.status, !!cfg.turnstileSitekey)) {
377
+ loadTurnstile();
378
+ if (turnstileToken) {
379
+ turnstileApi()?.reset?.();
380
+ turnstileToken = null;
381
+ }
382
+ const token = await waitForTurnstileToken(4000);
383
+ if (token) res = await postBody(body);
384
+ }
385
+ if (!res.ok || !res.body) {
386
+ throw new Error(`Request failed (${res.status})`);
387
+ }
388
+ const answerMd = await streamInto(res, answer);
389
+ // Offer thumbs feedback on the completed answer (no-op in search mode /
390
+ // when feedback is off / when the proxy stamped no id).
391
+ appendFeedback(answer.parentElement as HTMLElement, answer);
392
+ if (conversational) {
393
+ history.push({ role: "assistant", content: answerMd });
394
+ saveStoredHistory(history);
395
+ }
396
+ } catch (err) {
397
+ answer.dataset.state = "error";
398
+ answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
399
+ // Drop the unanswered user turn from history so the next send isn't
400
+ // skewed by a question that never got a reply.
401
+ if (conversational && history[history.length - 1]?.role === "user") history.pop();
402
+ } finally {
403
+ els.submit.disabled = false;
404
+ // Turnstile tokens are single-use — refresh for the next question.
405
+ turnstileApi()?.reset?.();
406
+ turnstileToken = null;
407
+ }
408
+ });
409
+ }
410
+
411
+ const ROOT_ID = "dba-root";
412
+
413
+ /**
414
+ * Wire the server-rendered docked panel if present. Returns whether a docked
415
+ * shell exists (so the caller knows to mark the floating button mobile-only).
416
+ */
417
+ function wireDockedPanel(): boolean {
418
+ const panel = document.querySelector<HTMLElement>('[data-dba-panel="rail"]');
419
+ if (!panel) return false;
420
+ if (panel.dataset.dbaWired) return true;
421
+ panel.dataset.dbaWired = "1";
422
+
423
+ const form = panel.querySelector<HTMLFormElement>("[data-dba-form]");
424
+ const input = panel.querySelector<HTMLInputElement>(".dba-input");
425
+ const submit = panel.querySelector<HTMLButtonElement>(".dba-submit");
426
+ const thread = panel.querySelector<HTMLElement>("[data-dba-thread]");
427
+ if (!form || !input || !submit || !thread) return true;
428
+
429
+ // Lazy-load Turnstile on first interaction so a page that merely shows the
430
+ // docked panel still sets no third-party cookies until the reader engages.
431
+ input.addEventListener("focus", loadTurnstile, { once: true });
432
+ wireConversation({
433
+ form,
434
+ input,
435
+ submit,
436
+ thread,
437
+ newChat: panel.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
438
+ remember: panel.querySelector<HTMLInputElement>("[data-dba-remember]"),
439
+ });
440
+ return true;
441
+ }
442
+
443
+ /**
444
+ * Build + wire the floating button + dialog. `hasDocked` marks the root so CSS
445
+ * can hide the trigger at desktop widths (where the docked panel is visible).
446
+ */
447
+ function mountFloating(hasDocked: boolean): void {
448
+ if (document.getElementById(ROOT_ID)) return;
449
+
450
+ const root = document.createElement("div");
451
+ root.id = ROOT_ID;
452
+ root.dataset.position = cfg.position;
453
+ if (hasDocked) root.dataset.hasDocked = "1";
454
+
455
+ const button = document.createElement("button");
456
+ button.type = "button";
457
+ button.className = "dba-trigger";
458
+ button.textContent = cfg.buttonLabel;
459
+ button.setAttribute("aria-haspopup", "dialog");
460
+
461
+ const dialog = document.createElement("dialog");
462
+ dialog.className = "dba-dialog";
463
+ dialog.setAttribute("aria-label", cfg.title);
464
+ const tsMarkup = cfg.turnstileSitekey
465
+ ? `<div class="cf-turnstile dba-turnstile" data-sitekey="${cfg.turnstileSitekey}" data-appearance="interaction-only" data-callback="__dbaTsCb" data-expired-callback="__dbaTsExpired" data-error-callback="__dbaTsExpired"></div>`
466
+ : "";
467
+ const newChatMarkup = cfg.conversational
468
+ ? `<button type="button" class="dba-newchat" data-dba-newchat>New chat</button>`
469
+ : "";
470
+ const rememberMarkup = cfg.conversational && cfg.persistConversation
471
+ ? `<label class="dba-remember"><input type="checkbox" data-dba-remember /> Remember conversation</label>`
472
+ : "";
473
+ dialog.innerHTML = `
474
+ <form class="dba-form" method="dialog" data-dba-form>
475
+ <header class="dba-header">
476
+ <h2 class="dba-title"></h2>
477
+ <div class="dba-header-actions">
478
+ ${newChatMarkup}
479
+ <button type="button" class="dba-close" aria-label="Close">×</button>
480
+ </div>
481
+ </header>
482
+ ${rememberMarkup}
483
+ <div class="dba-thread" data-dba-thread></div>
484
+ <div class="dba-inputrow">
485
+ <input class="dba-input" type="text" autocomplete="off" enterkeyhint="search" />
486
+ <button type="submit" class="dba-submit">Ask</button>
487
+ </div>
488
+ ${tsMarkup}
489
+ ${rememberMarkup}
490
+ </form>
491
+ `;
492
+
493
+ // textContent (not innerHTML) for user-configurable strings — no injection.
494
+ dialog.querySelector<HTMLHeadingElement>(".dba-title")!.textContent = cfg.title;
495
+ const input = dialog.querySelector<HTMLInputElement>(".dba-input")!;
496
+ input.placeholder = cfg.placeholder;
497
+ const form = dialog.querySelector<HTMLFormElement>(".dba-form")!;
498
+ const submit = dialog.querySelector<HTMLButtonElement>(".dba-submit")!;
499
+ const thread = dialog.querySelector<HTMLElement>("[data-dba-thread]")!;
500
+
501
+ button.addEventListener("click", () => {
502
+ // Lazy-load Turnstile only when the user engages, so normal page loads set
503
+ // no third-party cookies (Lighthouse-clean) and don't fetch the widget.
504
+ loadTurnstile();
505
+ dialog.showModal();
506
+ input.focus();
507
+ });
508
+ dialog.querySelector<HTMLButtonElement>(".dba-close")!.addEventListener("click", () => dialog.close());
509
+
510
+ wireConversation({
511
+ form,
512
+ input,
513
+ submit,
514
+ thread,
515
+ newChat: dialog.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
516
+ remember: dialog.querySelector<HTMLInputElement>("[data-dba-remember]"),
517
+ });
518
+
519
+ root.append(button, dialog);
520
+ document.body.appendChild(root);
521
+ }
522
+
523
+ function mount(): void {
524
+ if (typeof document === "undefined") return;
525
+ const hasDocked = wireDockedPanel();
526
+ mountFloating(hasDocked);
527
+ }
528
+
529
+ function unmount(): void {
530
+ if (typeof document === "undefined") return;
531
+ document.getElementById(ROOT_ID)?.remove();
532
+ }
533
+
534
+ if (typeof document !== "undefined") {
535
+ if (document.readyState === "loading") {
536
+ document.addEventListener("DOMContentLoaded", mount);
537
+ } else {
538
+ mount();
539
+ }
540
+ document.addEventListener("astro:page-load", mount);
541
+ document.addEventListener("astro:before-swap", unmount);
542
+ }