@dogsbay/plugin-ask 0.2.0-beta.87 → 0.2.0-beta.89
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/README.md +2 -0
- package/dist/client.js +293 -82
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/md.js +1 -1
- package/dist/md.js.map +1 -1
- package/dist/protocol.d.ts +25 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +26 -0
- package/dist/protocol.js.map +1 -1
- package/dist/schema.d.ts +22 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +21 -0
- package/dist/schema.js.map +1 -1
- package/package.json +2 -2
- package/src/RightRailAsk.astro +12 -1
- package/src/client.ts +292 -83
- package/src/styles.css +47 -1
package/src/client.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client runtime for `@dogsbay/plugin-ask`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Renders a multi-turn "Ask AI" conversation in two targets that share one
|
|
5
|
+
* code path:
|
|
6
6
|
* - **Docked panel** — a server-rendered shell (`RightRailAsk.astro`,
|
|
7
|
-
* `[data-dba-panel="rail"]`)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* there's no rail (floating placement, wide/API pages) and as the mobile
|
|
11
|
-
* entry when a docked panel exists.
|
|
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.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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).
|
|
16
15
|
*/
|
|
17
16
|
// @ts-expect-error: virtual module resolved by the Dogsbay Vite alias at build.
|
|
18
17
|
import config from "virtual:dogsbay-plugin-config/@dogsbay/plugin-ask";
|
|
19
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
buildAskRequest,
|
|
20
|
+
buildChatRequest,
|
|
21
|
+
trimHistory,
|
|
22
|
+
extractText,
|
|
23
|
+
parseSources,
|
|
24
|
+
shouldRetryTurnstile,
|
|
25
|
+
type ChatMessage,
|
|
26
|
+
type Source,
|
|
27
|
+
} from "./protocol.js";
|
|
20
28
|
import { renderMarkdown, escapeHtml } from "./md.js";
|
|
21
29
|
import type { AskMode, AskPlacement, AskPosition } from "./schema.js";
|
|
22
30
|
|
|
@@ -29,6 +37,9 @@ interface RuntimeConfig {
|
|
|
29
37
|
placeholder: string;
|
|
30
38
|
position: AskPosition;
|
|
31
39
|
placement: AskPlacement;
|
|
40
|
+
conversational: boolean;
|
|
41
|
+
maxTurns: number;
|
|
42
|
+
persistConversation: boolean;
|
|
32
43
|
linkBase: string;
|
|
33
44
|
turnstileSitekey: string;
|
|
34
45
|
}
|
|
@@ -63,83 +74,262 @@ function sourcesHtml(sources: Source[]): string {
|
|
|
63
74
|
const items = sources
|
|
64
75
|
.map((s) =>
|
|
65
76
|
s.href
|
|
66
|
-
? `<li><a href="${escapeHtml(s.href)}"
|
|
77
|
+
? `<li><a href="${escapeHtml(s.href)}">${escapeHtml(s.label)}</a></li>`
|
|
67
78
|
: `<li>${escapeHtml(s.label)}</li>`,
|
|
68
79
|
)
|
|
69
80
|
.join("");
|
|
70
81
|
return `<div class="dba-sources"><div class="dba-sources-title">Sources</div><ul>${items}</ul></div>`;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
// ─── Conversation persistence (sessionStorage; tab-scoped) ───────────────────
|
|
85
|
+
// Persisting the reader's own conversation lets it survive a page refresh and
|
|
86
|
+
// same-window navigation (e.g. clicking a source link). Tab-scoped, cleared on
|
|
87
|
+
// tab close, never sent anywhere. Gated by config + a per-tab reader opt-out.
|
|
88
|
+
|
|
89
|
+
const STORE = "dba-ask";
|
|
90
|
+
const OPT_OUT = "0";
|
|
91
|
+
const convoKey = () => `${STORE}:convo:${cfg.site}`;
|
|
92
|
+
const optOutKey = () => `${STORE}:save:${cfg.site}`;
|
|
93
|
+
|
|
94
|
+
function isPersistMode(): boolean {
|
|
95
|
+
return cfg.conversational && cfg.mode !== "search" && cfg.persistConversation;
|
|
96
|
+
}
|
|
97
|
+
function persistOptedOut(): boolean {
|
|
98
|
+
try { return sessionStorage.getItem(optOutKey()) === OPT_OUT; } catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
function persistEnabled(): boolean {
|
|
101
|
+
return isPersistMode() && !persistOptedOut();
|
|
102
|
+
}
|
|
103
|
+
function loadStoredHistory(): ChatMessage[] {
|
|
104
|
+
if (!persistEnabled()) return [];
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(sessionStorage.getItem(convoKey()) || "null");
|
|
107
|
+
if (!Array.isArray(parsed)) return [];
|
|
108
|
+
return parsed.filter(
|
|
109
|
+
(m): m is ChatMessage =>
|
|
110
|
+
m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string",
|
|
111
|
+
);
|
|
112
|
+
} catch { return []; }
|
|
113
|
+
}
|
|
114
|
+
function saveStoredHistory(history: ChatMessage[]): void {
|
|
115
|
+
if (!persistEnabled()) return;
|
|
116
|
+
try { sessionStorage.setItem(convoKey(), JSON.stringify(history)); } catch { /* storage off */ }
|
|
117
|
+
}
|
|
118
|
+
function clearStoredHistory(): void {
|
|
119
|
+
try { sessionStorage.removeItem(convoKey()); } catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
function setPersistOptOut(optedOut: boolean): void {
|
|
122
|
+
try {
|
|
123
|
+
if (optedOut) { sessionStorage.setItem(optOutKey(), OPT_OUT); clearStoredHistory(); }
|
|
124
|
+
else sessionStorage.removeItem(optOutKey());
|
|
125
|
+
} catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** POST a prebuilt request body with the current Turnstile token (if any). */
|
|
129
|
+
function postBody(body: object): Promise<Response> {
|
|
130
|
+
return fetch(cfg.endpoint, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ ...body, ...(turnstileToken ? { turnstileToken } : {}) }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resolve once a Turnstile token is available, or after `timeoutMs`. */
|
|
138
|
+
function waitForTurnstileToken(timeoutMs: number): Promise<string | null> {
|
|
139
|
+
if (turnstileToken) return Promise.resolve(turnstileToken);
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
const tick = () => {
|
|
143
|
+
if (turnstileToken) return resolve(turnstileToken);
|
|
144
|
+
if (Date.now() - start >= timeoutMs) return resolve(null);
|
|
145
|
+
setTimeout(tick, 150);
|
|
146
|
+
};
|
|
147
|
+
tick();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Nearest scrollable ancestor (the rail aside, or the dialog's thread). */
|
|
152
|
+
function scrollParent(el: HTMLElement): HTMLElement | null {
|
|
153
|
+
let p = el.parentElement;
|
|
154
|
+
while (p) {
|
|
155
|
+
const oy = getComputedStyle(p).overflowY;
|
|
156
|
+
if ((oy === "auto" || oy === "scroll") && p.scrollHeight > p.clientHeight) return p;
|
|
157
|
+
p = p.parentElement;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stream a proxy response into an answer element; returns the answer Markdown. */
|
|
163
|
+
async function streamInto(res: Response, answer: HTMLDivElement): Promise<string> {
|
|
164
|
+
answer.textContent = "";
|
|
165
|
+
answer.dataset.state = "streaming";
|
|
166
|
+
|
|
167
|
+
const sc = scrollParent(answer);
|
|
168
|
+
// Keep the newest content in view as it streams — but only if the reader is
|
|
169
|
+
// already at the bottom, so we don't yank them up while they re-read earlier.
|
|
170
|
+
const atBottom = () => !sc || sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 80;
|
|
171
|
+
const render = (html: string) => {
|
|
172
|
+
const stick = atBottom();
|
|
173
|
+
answer.innerHTML = html;
|
|
174
|
+
if (stick && sc) sc.scrollTop = sc.scrollHeight;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const reader = res.body!.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
let buf = ""; // line buffer (don't parse a frame split across reads)
|
|
180
|
+
let rawAll = ""; // whole stream, for the trailing `event: chunks` sources
|
|
181
|
+
let answerMd = ""; // accumulated answer markdown
|
|
182
|
+
for (;;) {
|
|
183
|
+
const { value, done } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
const text = decoder.decode(value, { stream: true });
|
|
186
|
+
rawAll += text;
|
|
187
|
+
buf += text;
|
|
188
|
+
const nl = buf.lastIndexOf("\n");
|
|
189
|
+
if (nl >= 0) {
|
|
190
|
+
answerMd += extractText(buf.slice(0, nl + 1));
|
|
191
|
+
buf = buf.slice(nl + 1);
|
|
192
|
+
render(renderMarkdown(answerMd));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (buf) answerMd += extractText(buf);
|
|
196
|
+
const sources = parseSources(rawAll, cfg.linkBase);
|
|
197
|
+
render(renderMarkdown(answerMd) + (sources.length ? sourcesHtml(sources) : ""));
|
|
198
|
+
answer.dataset.state = "done";
|
|
199
|
+
return answerMd;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function appendUserTurn(thread: HTMLElement, text: string): void {
|
|
203
|
+
const turn = document.createElement("div");
|
|
204
|
+
turn.className = "dba-turn dba-turn-user";
|
|
205
|
+
turn.textContent = text; // textContent — no injection from the question
|
|
206
|
+
thread.appendChild(turn);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function appendAssistantTurn(thread: HTMLElement): HTMLDivElement {
|
|
210
|
+
const turn = document.createElement("div");
|
|
211
|
+
turn.className = "dba-turn dba-turn-assistant";
|
|
212
|
+
const answer = document.createElement("div");
|
|
213
|
+
answer.className = "dba-answer";
|
|
214
|
+
answer.setAttribute("aria-live", "polite");
|
|
215
|
+
answer.dataset.state = "loading";
|
|
216
|
+
answer.textContent = "…";
|
|
217
|
+
turn.appendChild(answer);
|
|
218
|
+
thread.appendChild(turn);
|
|
219
|
+
return answer;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface ConvoEls {
|
|
74
223
|
form: HTMLFormElement;
|
|
75
224
|
input: HTMLInputElement;
|
|
76
|
-
answer: HTMLDivElement;
|
|
77
225
|
submit: HTMLButtonElement;
|
|
226
|
+
thread: HTMLElement;
|
|
227
|
+
newChat?: HTMLButtonElement | null;
|
|
228
|
+
remember?: HTMLInputElement | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Re-render a stored conversation into the thread (no streaming). */
|
|
232
|
+
function restoreThread(thread: HTMLElement, history: ChatMessage[]): void {
|
|
233
|
+
for (const m of history) {
|
|
234
|
+
if (m.role === "user") {
|
|
235
|
+
appendUserTurn(thread, m.content);
|
|
236
|
+
} else {
|
|
237
|
+
const a = appendAssistantTurn(thread);
|
|
238
|
+
a.dataset.state = "done";
|
|
239
|
+
a.innerHTML = renderMarkdown(m.content);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
78
242
|
}
|
|
79
243
|
|
|
80
244
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
245
|
+
* Wire one conversation surface (docked panel or floating dialog). Each keeps
|
|
246
|
+
* its own history closure. Shares the stream + Turnstile-retry logic.
|
|
83
247
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
248
|
+
function wireConversation(els: ConvoEls): void {
|
|
249
|
+
// Restore a persisted conversation (refresh / same-window navigation).
|
|
250
|
+
const history: ChatMessage[] = loadStoredHistory();
|
|
251
|
+
if (history.length) restoreThread(els.thread, history);
|
|
87
252
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
headers: { "Content-Type": "application/json" },
|
|
96
|
-
body: JSON.stringify({
|
|
97
|
-
...buildAskRequest(cfg.site, query, cfg.mode),
|
|
98
|
-
...(turnstileToken ? { turnstileToken } : {}),
|
|
99
|
-
}),
|
|
253
|
+
// Reader opt-out toggle: reflect the stored preference, and on change
|
|
254
|
+
// start/stop persisting (clearing storage on opt-out).
|
|
255
|
+
if (els.remember) {
|
|
256
|
+
els.remember.checked = !persistOptedOut();
|
|
257
|
+
els.remember.addEventListener("change", () => {
|
|
258
|
+
setPersistOptOut(!els.remember!.checked);
|
|
259
|
+
if (els.remember!.checked) saveStoredHistory(history);
|
|
100
260
|
});
|
|
101
|
-
if (!res.ok || !res.body) {
|
|
102
|
-
throw new Error(`Request failed (${res.status})`);
|
|
103
|
-
}
|
|
104
|
-
answer.textContent = "";
|
|
105
|
-
answer.dataset.state = "streaming";
|
|
106
|
-
const reader = res.body.getReader();
|
|
107
|
-
const decoder = new TextDecoder();
|
|
108
|
-
let buf = ""; // line buffer (don't parse a frame split across reads)
|
|
109
|
-
let rawAll = ""; // whole stream, for the trailing `event: chunks` sources
|
|
110
|
-
let answerMd = ""; // accumulated answer markdown
|
|
111
|
-
for (;;) {
|
|
112
|
-
const { value, done } = await reader.read();
|
|
113
|
-
if (done) break;
|
|
114
|
-
const text = decoder.decode(value, { stream: true });
|
|
115
|
-
rawAll += text;
|
|
116
|
-
buf += text;
|
|
117
|
-
const nl = buf.lastIndexOf("\n");
|
|
118
|
-
if (nl >= 0) {
|
|
119
|
-
answerMd += extractText(buf.slice(0, nl + 1));
|
|
120
|
-
buf = buf.slice(nl + 1);
|
|
121
|
-
answer.innerHTML = renderMarkdown(answerMd);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (buf) answerMd += extractText(buf);
|
|
125
|
-
const sources = parseSources(rawAll, cfg.linkBase);
|
|
126
|
-
answer.innerHTML = renderMarkdown(answerMd) + (sources.length ? sourcesHtml(sources) : "");
|
|
127
|
-
answer.dataset.state = "done";
|
|
128
|
-
} catch (err) {
|
|
129
|
-
answer.dataset.state = "error";
|
|
130
|
-
answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
|
|
131
|
-
} finally {
|
|
132
|
-
submit.disabled = false;
|
|
133
|
-
// Turnstile tokens are single-use — refresh for the next question.
|
|
134
|
-
turnstileApi()?.reset?.();
|
|
135
|
-
turnstileToken = null;
|
|
136
261
|
}
|
|
137
|
-
}
|
|
138
262
|
|
|
139
|
-
|
|
140
|
-
|
|
263
|
+
const reset = () => {
|
|
264
|
+
els.thread.innerHTML = "";
|
|
265
|
+
history.length = 0;
|
|
266
|
+
clearStoredHistory();
|
|
267
|
+
};
|
|
268
|
+
els.newChat?.addEventListener("click", () => {
|
|
269
|
+
reset();
|
|
270
|
+
els.input.focus();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
els.form.addEventListener("submit", async (e) => {
|
|
141
274
|
e.preventDefault();
|
|
142
|
-
|
|
275
|
+
const query = els.input.value.trim();
|
|
276
|
+
if (!query) return;
|
|
277
|
+
els.input.value = "";
|
|
278
|
+
|
|
279
|
+
// Single-shot modes show one exchange at a time.
|
|
280
|
+
const conversational = cfg.conversational && cfg.mode !== "search";
|
|
281
|
+
if (!conversational) els.thread.innerHTML = "";
|
|
282
|
+
|
|
283
|
+
appendUserTurn(els.thread, query);
|
|
284
|
+
const answer = appendAssistantTurn(els.thread);
|
|
285
|
+
answer.scrollIntoView?.({ block: "nearest" });
|
|
286
|
+
els.submit.disabled = true;
|
|
287
|
+
|
|
288
|
+
// Build the request body. Conversational answer mode sends trimmed history;
|
|
289
|
+
// search and non-conversational answer stay single-shot.
|
|
290
|
+
let body: object;
|
|
291
|
+
if (cfg.mode === "search") {
|
|
292
|
+
body = buildAskRequest(cfg.site, query, "search");
|
|
293
|
+
} else if (conversational) {
|
|
294
|
+
history.push({ role: "user", content: query });
|
|
295
|
+
body = buildChatRequest(cfg.site, trimHistory(history, cfg.maxTurns));
|
|
296
|
+
} else {
|
|
297
|
+
body = buildAskRequest(cfg.site, query, "answer");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
let res = await postBody(body);
|
|
302
|
+
// Turnstile race: lazy-loaded on first interaction, so a fast submit can
|
|
303
|
+
// beat the token and the proxy 403s. Wait once for a fresh token + retry.
|
|
304
|
+
if (shouldRetryTurnstile(res.status, !!cfg.turnstileSitekey)) {
|
|
305
|
+
loadTurnstile();
|
|
306
|
+
if (turnstileToken) {
|
|
307
|
+
turnstileApi()?.reset?.();
|
|
308
|
+
turnstileToken = null;
|
|
309
|
+
}
|
|
310
|
+
const token = await waitForTurnstileToken(4000);
|
|
311
|
+
if (token) res = await postBody(body);
|
|
312
|
+
}
|
|
313
|
+
if (!res.ok || !res.body) {
|
|
314
|
+
throw new Error(`Request failed (${res.status})`);
|
|
315
|
+
}
|
|
316
|
+
const answerMd = await streamInto(res, answer);
|
|
317
|
+
if (conversational) {
|
|
318
|
+
history.push({ role: "assistant", content: answerMd });
|
|
319
|
+
saveStoredHistory(history);
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
answer.dataset.state = "error";
|
|
323
|
+
answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
|
|
324
|
+
// Drop the unanswered user turn from history so the next send isn't
|
|
325
|
+
// skewed by a question that never got a reply.
|
|
326
|
+
if (conversational && history[history.length - 1]?.role === "user") history.pop();
|
|
327
|
+
} finally {
|
|
328
|
+
els.submit.disabled = false;
|
|
329
|
+
// Turnstile tokens are single-use — refresh for the next question.
|
|
330
|
+
turnstileApi()?.reset?.();
|
|
331
|
+
turnstileToken = null;
|
|
332
|
+
}
|
|
143
333
|
});
|
|
144
334
|
}
|
|
145
335
|
|
|
@@ -147,8 +337,7 @@ const ROOT_ID = "dba-root";
|
|
|
147
337
|
|
|
148
338
|
/**
|
|
149
339
|
* Wire the server-rendered docked panel if present. Returns whether a docked
|
|
150
|
-
* shell exists
|
|
151
|
-
* a mobile-only entry). Idempotent via a `data-dba-wired` marker.
|
|
340
|
+
* shell exists (so the caller knows to mark the floating button mobile-only).
|
|
152
341
|
*/
|
|
153
342
|
function wireDockedPanel(): boolean {
|
|
154
343
|
const panel = document.querySelector<HTMLElement>('[data-dba-panel="rail"]');
|
|
@@ -158,14 +347,21 @@ function wireDockedPanel(): boolean {
|
|
|
158
347
|
|
|
159
348
|
const form = panel.querySelector<HTMLFormElement>("[data-dba-form]");
|
|
160
349
|
const input = panel.querySelector<HTMLInputElement>(".dba-input");
|
|
161
|
-
const answer = panel.querySelector<HTMLDivElement>(".dba-answer");
|
|
162
350
|
const submit = panel.querySelector<HTMLButtonElement>(".dba-submit");
|
|
163
|
-
|
|
351
|
+
const thread = panel.querySelector<HTMLElement>("[data-dba-thread]");
|
|
352
|
+
if (!form || !input || !submit || !thread) return true;
|
|
164
353
|
|
|
165
354
|
// Lazy-load Turnstile on first interaction so a page that merely shows the
|
|
166
355
|
// docked panel still sets no third-party cookies until the reader engages.
|
|
167
356
|
input.addEventListener("focus", loadTurnstile, { once: true });
|
|
168
|
-
|
|
357
|
+
wireConversation({
|
|
358
|
+
form,
|
|
359
|
+
input,
|
|
360
|
+
submit,
|
|
361
|
+
thread,
|
|
362
|
+
newChat: panel.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
|
|
363
|
+
remember: panel.querySelector<HTMLInputElement>("[data-dba-remember]"),
|
|
364
|
+
});
|
|
169
365
|
return true;
|
|
170
366
|
}
|
|
171
367
|
|
|
@@ -193,18 +389,28 @@ function mountFloating(hasDocked: boolean): void {
|
|
|
193
389
|
const tsMarkup = cfg.turnstileSitekey
|
|
194
390
|
? `<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>`
|
|
195
391
|
: "";
|
|
392
|
+
const newChatMarkup = cfg.conversational
|
|
393
|
+
? `<button type="button" class="dba-newchat" data-dba-newchat>New chat</button>`
|
|
394
|
+
: "";
|
|
395
|
+
const rememberMarkup = cfg.conversational && cfg.persistConversation
|
|
396
|
+
? `<label class="dba-remember"><input type="checkbox" data-dba-remember checked /> Remember conversation</label>`
|
|
397
|
+
: "";
|
|
196
398
|
dialog.innerHTML = `
|
|
197
399
|
<form class="dba-form" method="dialog" data-dba-form>
|
|
198
400
|
<header class="dba-header">
|
|
199
401
|
<h2 class="dba-title"></h2>
|
|
200
|
-
<
|
|
402
|
+
<div class="dba-header-actions">
|
|
403
|
+
${newChatMarkup}
|
|
404
|
+
<button type="button" class="dba-close" aria-label="Close">×</button>
|
|
405
|
+
</div>
|
|
201
406
|
</header>
|
|
407
|
+
<div class="dba-thread" data-dba-thread></div>
|
|
202
408
|
<div class="dba-inputrow">
|
|
203
409
|
<input class="dba-input" type="text" autocomplete="off" enterkeyhint="search" />
|
|
204
410
|
<button type="submit" class="dba-submit">Ask</button>
|
|
205
411
|
</div>
|
|
206
412
|
${tsMarkup}
|
|
207
|
-
|
|
413
|
+
${rememberMarkup}
|
|
208
414
|
</form>
|
|
209
415
|
`;
|
|
210
416
|
|
|
@@ -212,9 +418,9 @@ function mountFloating(hasDocked: boolean): void {
|
|
|
212
418
|
dialog.querySelector<HTMLHeadingElement>(".dba-title")!.textContent = cfg.title;
|
|
213
419
|
const input = dialog.querySelector<HTMLInputElement>(".dba-input")!;
|
|
214
420
|
input.placeholder = cfg.placeholder;
|
|
215
|
-
const answer = dialog.querySelector<HTMLDivElement>(".dba-answer")!;
|
|
216
421
|
const form = dialog.querySelector<HTMLFormElement>(".dba-form")!;
|
|
217
422
|
const submit = dialog.querySelector<HTMLButtonElement>(".dba-submit")!;
|
|
423
|
+
const thread = dialog.querySelector<HTMLElement>("[data-dba-thread]")!;
|
|
218
424
|
|
|
219
425
|
button.addEventListener("click", () => {
|
|
220
426
|
// Lazy-load Turnstile only when the user engages, so normal page loads set
|
|
@@ -225,7 +431,14 @@ function mountFloating(hasDocked: boolean): void {
|
|
|
225
431
|
});
|
|
226
432
|
dialog.querySelector<HTMLButtonElement>(".dba-close")!.addEventListener("click", () => dialog.close());
|
|
227
433
|
|
|
228
|
-
|
|
434
|
+
wireConversation({
|
|
435
|
+
form,
|
|
436
|
+
input,
|
|
437
|
+
submit,
|
|
438
|
+
thread,
|
|
439
|
+
newChat: dialog.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
|
|
440
|
+
remember: dialog.querySelector<HTMLInputElement>("[data-dba-remember]"),
|
|
441
|
+
});
|
|
229
442
|
|
|
230
443
|
root.append(button, dialog);
|
|
231
444
|
document.body.appendChild(root);
|
|
@@ -233,16 +446,12 @@ function mountFloating(hasDocked: boolean): void {
|
|
|
233
446
|
|
|
234
447
|
function mount(): void {
|
|
235
448
|
if (typeof document === "undefined") return;
|
|
236
|
-
// Wire the docked panel first; its presence decides whether the floating
|
|
237
|
-
// button is a full widget (no rail) or just the mobile entry (rail present).
|
|
238
449
|
const hasDocked = wireDockedPanel();
|
|
239
450
|
mountFloating(hasDocked);
|
|
240
451
|
}
|
|
241
452
|
|
|
242
453
|
function unmount(): void {
|
|
243
454
|
if (typeof document === "undefined") return;
|
|
244
|
-
// Only the floating widget is ours to remove; the docked shell is part of the
|
|
245
|
-
// document Astro swaps, and is re-wired by mount() on the next page-load.
|
|
246
455
|
document.getElementById(ROOT_ID)?.remove();
|
|
247
456
|
}
|
|
248
457
|
|
package/src/styles.css
CHANGED
|
@@ -28,8 +28,54 @@
|
|
|
28
28
|
/* The docked form fills the rail column. */
|
|
29
29
|
.dba-panel .dba-form { padding: 0; gap: 0.5rem; }
|
|
30
30
|
.dba-panel .dba-title { font-size: 0.95rem; }
|
|
31
|
-
/* The rail aside scrolls, so let
|
|
31
|
+
/* The rail aside scrolls, so let answers grow rather than scroll internally. */
|
|
32
32
|
.dba-panel .dba-answer { max-height: none; }
|
|
33
|
+
/* Keep the input visible while the rail scrolls the conversation. */
|
|
34
|
+
.dba-panel .dba-inputrow {
|
|
35
|
+
position: sticky;
|
|
36
|
+
bottom: 0;
|
|
37
|
+
background: var(--dba-surface);
|
|
38
|
+
padding-block: 0.4rem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Conversation thread (docked panel + floating dialog). */
|
|
42
|
+
.dba-thread { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
43
|
+
.dba-thread:empty { display: none; }
|
|
44
|
+
.dba-turn-user {
|
|
45
|
+
align-self: flex-end;
|
|
46
|
+
max-width: 92%;
|
|
47
|
+
padding: 0.4rem 0.7rem;
|
|
48
|
+
white-space: pre-wrap;
|
|
49
|
+
background: color-mix(in srgb, var(--dba-accent) 14%, transparent);
|
|
50
|
+
border-radius: var(--dba-radius);
|
|
51
|
+
}
|
|
52
|
+
.dba-turn-assistant { align-self: stretch; }
|
|
53
|
+
.dba-header-actions { display: flex; align-items: center; gap: 0.4rem; }
|
|
54
|
+
.dba-newchat {
|
|
55
|
+
font: inherit;
|
|
56
|
+
font-size: 0.78rem;
|
|
57
|
+
padding: 0.2rem 0.5rem;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
color: var(--dba-text);
|
|
60
|
+
opacity: 0.7;
|
|
61
|
+
background: none;
|
|
62
|
+
border: 1px solid var(--dba-border);
|
|
63
|
+
border-radius: var(--dba-radius);
|
|
64
|
+
}
|
|
65
|
+
.dba-newchat:hover { opacity: 1; }
|
|
66
|
+
/* In the modal, bound the thread so it scrolls and the input stays visible. */
|
|
67
|
+
.dba-dialog .dba-thread { max-height: 55vh; overflow-y: auto; }
|
|
68
|
+
|
|
69
|
+
/* Reader opt-out for conversation persistence. */
|
|
70
|
+
.dba-remember {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 0.4rem;
|
|
74
|
+
font-size: 0.78rem;
|
|
75
|
+
opacity: 0.75;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
.dba-remember input { cursor: pointer; }
|
|
33
79
|
|
|
34
80
|
.dba-trigger {
|
|
35
81
|
position: fixed;
|