@dogsbay/plugin-ask 0.2.0-beta.81

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 ADDED
@@ -0,0 +1,45 @@
1
+ # @dogsbay/plugin-ask
2
+
3
+ A floating "Ask AI" button for Dogsbay docs sites. It opens a modal, sends the
4
+ question to a configured backend, and shows the answer — so a static docs site
5
+ can offer AI search without shipping any secrets.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @dogsbay/plugin-ask
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```yaml
16
+ # dogsbay.config.yml
17
+ plugins:
18
+ - name: "@dogsbay/plugin-ask"
19
+ options:
20
+ endpoint: "https://<your-ask-endpoint>"
21
+ site: "openshift-docs-dogsbay"
22
+ ```
23
+
24
+ `endpoint` and `site` are required; everything else is presentation.
25
+
26
+ ## Options
27
+
28
+ | Option | Required | Default | Description |
29
+ |---|---|---|---|
30
+ | `endpoint` | yes | — | URL of the backend the widget calls. |
31
+ | `site` | yes | — | Site id sent with each request to select the corpus. |
32
+ | `mode` | no | `answer` | `answer` (generated answer) or `search` (results). |
33
+ | `buttonLabel` | no | `Ask AI` | Floating button text. |
34
+ | `title` | no | `Ask the docs` | Modal heading. |
35
+ | `placeholder` | no | `Ask a question…` | Input placeholder. |
36
+ | `position` | no | `bottom-right` | `bottom-right` or `bottom-left`. |
37
+
38
+ ## Styling
39
+
40
+ Styles use Dogsbay theme tokens with fallbacks; override the `.dba-*` classes to
41
+ customize.
42
+
43
+ ## License
44
+
45
+ MIT.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":""}
package/dist/client.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Client runtime for `@dogsbay/plugin-ask`.
3
+ *
4
+ * Imported via the auto-generated `src/lib/plugin-runtime.ts` in the user's
5
+ * Astro project, so it runs for side effects on every page. It injects a
6
+ * floating button + <dialog> at <body> level (outside the article), and on
7
+ * submit streams an answer from the dogsbay-ask proxy.
8
+ *
9
+ * Astro view transitions: the document is swapped on navigation, so we mount
10
+ * on `astro:page-load` and tear down on `astro:before-swap` to avoid stale
11
+ * nodes / duplicate buttons.
12
+ */
13
+ // @ts-expect-error: virtual module resolved by the Dogsbay Vite alias at build.
14
+ import config from "virtual:dogsbay-plugin-config/@dogsbay/plugin-ask";
15
+ import { buildAskRequest, extractText } from "./protocol.js";
16
+ const cfg = config;
17
+ const ROOT_ID = "dba-root";
18
+ function mount() {
19
+ if (typeof document === "undefined")
20
+ return;
21
+ if (document.getElementById(ROOT_ID))
22
+ return; // already mounted
23
+ const root = document.createElement("div");
24
+ root.id = ROOT_ID;
25
+ root.dataset.position = cfg.position;
26
+ const button = document.createElement("button");
27
+ button.type = "button";
28
+ button.className = "dba-trigger";
29
+ button.textContent = cfg.buttonLabel;
30
+ button.setAttribute("aria-haspopup", "dialog");
31
+ const dialog = document.createElement("dialog");
32
+ dialog.className = "dba-dialog";
33
+ dialog.setAttribute("aria-label", cfg.title);
34
+ dialog.innerHTML = `
35
+ <form class="dba-form" method="dialog">
36
+ <header class="dba-header">
37
+ <h2 class="dba-title"></h2>
38
+ <button type="button" class="dba-close" aria-label="Close">×</button>
39
+ </header>
40
+ <div class="dba-inputrow">
41
+ <input class="dba-input" type="text" autocomplete="off" enterkeyhint="search" />
42
+ <button type="submit" class="dba-submit">Ask</button>
43
+ </div>
44
+ <div class="dba-answer" aria-live="polite" tabindex="-1"></div>
45
+ </form>
46
+ `;
47
+ // textContent (not innerHTML) for user-configurable strings — no injection.
48
+ dialog.querySelector(".dba-title").textContent = cfg.title;
49
+ const input = dialog.querySelector(".dba-input");
50
+ input.placeholder = cfg.placeholder;
51
+ const answer = dialog.querySelector(".dba-answer");
52
+ const form = dialog.querySelector(".dba-form");
53
+ const submit = dialog.querySelector(".dba-submit");
54
+ button.addEventListener("click", () => {
55
+ dialog.showModal();
56
+ input.focus();
57
+ });
58
+ dialog.querySelector(".dba-close").addEventListener("click", () => dialog.close());
59
+ form.addEventListener("submit", async (e) => {
60
+ e.preventDefault();
61
+ const query = input.value.trim();
62
+ if (!query)
63
+ return;
64
+ submit.disabled = true;
65
+ answer.dataset.state = "loading";
66
+ answer.textContent = "…";
67
+ try {
68
+ const res = await fetch(cfg.endpoint, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(buildAskRequest(cfg.site, query, cfg.mode)),
72
+ });
73
+ if (!res.ok || !res.body) {
74
+ throw new Error(`Request failed (${res.status})`);
75
+ }
76
+ answer.textContent = "";
77
+ answer.dataset.state = "streaming";
78
+ const reader = res.body.getReader();
79
+ const decoder = new TextDecoder();
80
+ let buf = "";
81
+ for (;;) {
82
+ const { value, done } = await reader.read();
83
+ if (done)
84
+ break;
85
+ buf += decoder.decode(value, { stream: true });
86
+ // Only process complete lines; keep any partial line buffered so a
87
+ // `data:` frame split across reads isn't parsed half-formed.
88
+ const nl = buf.lastIndexOf("\n");
89
+ if (nl >= 0) {
90
+ answer.textContent += extractText(buf.slice(0, nl + 1));
91
+ buf = buf.slice(nl + 1);
92
+ }
93
+ }
94
+ if (buf)
95
+ answer.textContent += extractText(buf);
96
+ answer.dataset.state = "done";
97
+ }
98
+ catch (err) {
99
+ answer.dataset.state = "error";
100
+ answer.textContent = `Sorry — something went wrong. ${err.message}`;
101
+ }
102
+ finally {
103
+ submit.disabled = false;
104
+ }
105
+ });
106
+ root.append(button, dialog);
107
+ document.body.appendChild(root);
108
+ }
109
+ function unmount() {
110
+ if (typeof document === "undefined")
111
+ return;
112
+ document.getElementById(ROOT_ID)?.remove();
113
+ }
114
+ if (typeof document !== "undefined") {
115
+ if (document.readyState === "loading") {
116
+ document.addEventListener("DOMContentLoaded", mount);
117
+ }
118
+ else {
119
+ mount();
120
+ }
121
+ document.addEventListener("astro:page-load", mount);
122
+ document.addEventListener("astro:before-swap", unmount);
123
+ }
124
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { DogsbayPlugin } from "@dogsbay/types";
2
+ import { type AskOptions } from "./schema.js";
3
+ export type { AskOptions } from "./schema.js";
4
+ export default function ask(input?: AskOptions): DogsbayPlugin;
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAS5D,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,CAAC,OAAO,UAAU,GAAG,CAAC,KAAK,CAAC,EAAE,UAAU,GAAG,aAAa,CA4B7D"}
package/dist/index.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `@dogsbay/plugin-ask` — adds a floating "Ask AI" widget to every page of a
3
+ * Dogsbay docs site. The widget POSTs the user's question to a shared
4
+ * dogsbay-ask proxy (a Cloudflare Worker over AI Search) and streams back a
5
+ * grounded answer. No build-time content transform — purely a client module +
6
+ * styles + a build-time→client config bridge.
7
+ *
8
+ * Usage:
9
+ *
10
+ * # dogsbay.config.yml
11
+ * plugins:
12
+ * - name: "@dogsbay/plugin-ask"
13
+ * options:
14
+ * endpoint: "https://dogsbay-ask.<subdomain>.workers.dev"
15
+ * site: "openshift-docs-dogsbay"
16
+ *
17
+ * The `endpoint` + `site` are required; everything else is presentation.
18
+ */
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { parseOptions } from "./schema.js";
22
+ // __dirname is `dist/` for the published package. Client JS is built next to
23
+ // this file; styles ship un-built at the package root under `src/`.
24
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
25
+ const PACKAGE_ROOT = path.resolve(PLUGIN_DIR, "..");
26
+ const CLIENT_PATH = path.join(PLUGIN_DIR, "client.js");
27
+ const STYLES_PATH = path.join(PACKAGE_ROOT, "src", "styles.css");
28
+ export default function ask(input) {
29
+ const opts = parseOptions(input);
30
+ return {
31
+ name: "@dogsbay/plugin-ask",
32
+ clientModules() {
33
+ return [CLIENT_PATH];
34
+ },
35
+ styles() {
36
+ return [STYLES_PATH];
37
+ },
38
+ defineClientConfig() {
39
+ // JSON-safe runtime config exposed at
40
+ // virtual:dogsbay-plugin-config/@dogsbay/plugin-ask
41
+ return {
42
+ endpoint: opts.endpoint,
43
+ site: opts.site,
44
+ mode: opts.mode,
45
+ buttonLabel: opts.buttonLabel,
46
+ title: opts.title,
47
+ placeholder: opts.placeholder,
48
+ position: opts.position,
49
+ };
50
+ },
51
+ };
52
+ }
53
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,YAAY,EAAmB,MAAM,aAAa,CAAC;AAE5D,6EAA6E;AAC7E,oEAAoE;AACpE,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAChE,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;AACpD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;AACvD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC;AAIjE,MAAM,CAAC,OAAO,UAAU,GAAG,CAAC,KAAkB;IAC5C,MAAM,IAAI,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IAEjC,OAAO;QACL,IAAI,EAAE,qBAAqB;QAE3B,aAAa;YACX,OAAO,CAAC,WAAW,CAAC,CAAC;QACvB,CAAC;QAED,MAAM;YACJ,OAAO,CAAC,WAAW,CAAC,CAAC;QACvB,CAAC;QAED,kBAAkB;YAChB,sCAAsC;YACtC,oDAAoD;YACpD,OAAO;gBACL,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,WAAW,EAAE,IAAI,CAAC,WAAW;gBAC7B,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,WAAW,EAAE,IAAI,CAAC,WAAW;gBAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ;aACxB,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Wire protocol shared between the client widget and the dogsbay-ask proxy.
3
+ * Kept dependency-free and DOM-free so it is unit-testable in Node.
4
+ */
5
+ import type { AskMode } from "./schema.js";
6
+ export interface AskRequest {
7
+ site: string;
8
+ query: string;
9
+ mode: AskMode;
10
+ }
11
+ /** Build the JSON body the proxy expects. Trims the query. */
12
+ export declare function buildAskRequest(site: string, query: string, mode: AskMode): AskRequest;
13
+ /**
14
+ * Tolerant extraction of human-readable text from a streamed proxy chunk.
15
+ *
16
+ * The proxy passes AI Search's streamed answer straight through. Depending on
17
+ * the instance/model that arrives either as raw text or as SSE `data:` frames.
18
+ * This strips common SSE framing and ignores the `[DONE]` sentinel so the
19
+ * widget can append whatever is left. Returns "" for control-only lines.
20
+ *
21
+ * NOTE: citation/structured-answer rendering is finalized against a live
22
+ * instance — see README "Streaming format".
23
+ */
24
+ export declare function extractText(chunk: string): string;
25
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf;AAED,8DAA8D;AAC9D,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,UAAU,CAEtF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CA8BjD"}
@@ -0,0 +1,51 @@
1
+ /** Build the JSON body the proxy expects. Trims the query. */
2
+ export function buildAskRequest(site, query, mode) {
3
+ return { site, query: query.trim(), mode };
4
+ }
5
+ /**
6
+ * Tolerant extraction of human-readable text from a streamed proxy chunk.
7
+ *
8
+ * The proxy passes AI Search's streamed answer straight through. Depending on
9
+ * the instance/model that arrives either as raw text or as SSE `data:` frames.
10
+ * This strips common SSE framing and ignores the `[DONE]` sentinel so the
11
+ * widget can append whatever is left. Returns "" for control-only lines.
12
+ *
13
+ * NOTE: citation/structured-answer rendering is finalized against a live
14
+ * instance — see README "Streaming format".
15
+ */
16
+ export function extractText(chunk) {
17
+ return chunk
18
+ .split(/\r?\n/)
19
+ .map((line) => {
20
+ const trimmed = line.trimStart();
21
+ if (trimmed === "")
22
+ return "";
23
+ // Drop SSE control framing (event:/id:/retry:/comment) — only `data:`
24
+ // lines carry content. A `data: [...]` chunks array parses to an array
25
+ // with no token field and is harmlessly skipped below.
26
+ if (/^(event|id|retry):/.test(trimmed) || trimmed.startsWith(":"))
27
+ return "";
28
+ if (!trimmed.startsWith("data:"))
29
+ return line;
30
+ const payload = trimmed.slice("data:".length).trim();
31
+ if (payload === "" || payload === "[DONE]")
32
+ return "";
33
+ // Try to pull a token out of a JSON event; else emit raw payload.
34
+ // Handles OpenAI-compatible chat-completions chunks
35
+ // ({choices:[{delta:{content}}]}) plus a few simpler shapes.
36
+ try {
37
+ const obj = JSON.parse(payload);
38
+ const token = obj.choices?.[0]?.delta?.content ??
39
+ obj.choices?.[0]?.message?.content ??
40
+ obj.response ??
41
+ obj.text ??
42
+ obj.delta;
43
+ return typeof token === "string" ? token : "";
44
+ }
45
+ catch {
46
+ return payload;
47
+ }
48
+ })
49
+ .join("");
50
+ }
51
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAYA,8DAA8D;AAC9D,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,KAAa,EAAE,IAAa;IACxE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,KAAK;SACT,KAAK,CAAC,OAAO,CAAC;SACd,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACZ,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QACjC,IAAI,OAAO,KAAK,EAAE;YAAE,OAAO,EAAE,CAAC;QAC9B,sEAAsE;QACtE,uEAAuE;QACvE,uDAAuD;QACvD,IAAI,oBAAoB,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,EAAE,CAAC;QAC7E,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAC9C,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QACrD,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QACtD,kEAAkE;QAClE,oDAAoD;QACpD,6DAA6D;QAC7D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAwB,CAAC;YACvD,MAAM,KAAK,GACT,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO;gBAChC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO;gBAClC,GAAG,CAAC,QAAQ;gBACZ,GAAG,CAAC,IAAI;gBACR,GAAG,CAAC,KAAK,CAAC;YACZ,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,OAAO,CAAC;QACjB,CAAC;IACH,CAAC,CAAC;SACD,IAAI,CAAC,EAAE,CAAC,CAAC;AACd,CAAC"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Options schema for `@dogsbay/plugin-ask`.
3
+ *
4
+ * Hand-rolled validation (no Zod dep), matching the convention in
5
+ * `@dogsbay/plugin-image-zoom`. `parseOptions` returns a fully-resolved
6
+ * options object with every field set to either the user value or the
7
+ * documented default. `endpoint` and `site` are REQUIRED — the widget is
8
+ * useless without a backend to call and a site id to route by.
9
+ */
10
+ export type AskMode = "answer" | "search";
11
+ export type AskPosition = "bottom-right" | "bottom-left";
12
+ export interface AskOptions {
13
+ /**
14
+ * URL of the dogsbay-ask proxy (the shared Worker). Required.
15
+ * Example: "https://dogsbay-ask.<subdomain>.workers.dev".
16
+ */
17
+ endpoint?: string;
18
+ /**
19
+ * Site id sent to the proxy to select the AI Search instance. Required.
20
+ * This is the site's GitHub Pages subfolder / Dogsbay base path, e.g.
21
+ * "openshift-docs-dogsbay". Must match a key in the proxy's SITES map.
22
+ */
23
+ site?: string;
24
+ /** Retrieval mode: "answer" (RAG answer, default) or "search" (chunks). */
25
+ mode?: AskMode;
26
+ /** Floating button label. Default "Ask AI". */
27
+ buttonLabel?: string;
28
+ /** Modal heading. Default "Ask the docs". */
29
+ title?: string;
30
+ /** Input placeholder. Default "Ask a question…". */
31
+ placeholder?: string;
32
+ /** Corner the floating button sits in. Default "bottom-right". */
33
+ position?: AskPosition;
34
+ }
35
+ export interface ResolvedAskOptions {
36
+ endpoint: string;
37
+ site: string;
38
+ mode: AskMode;
39
+ buttonLabel: string;
40
+ title: string;
41
+ placeholder: string;
42
+ position: AskPosition;
43
+ }
44
+ /**
45
+ * Validate + resolve user options. Throws structured errors naming the
46
+ * offending field so users see a clear message rather than a stack trace.
47
+ */
48
+ export declare function parseOptions(input: unknown): ResolvedAskOptions;
49
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAC1C,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,aAAa,CAAC;AAEzD,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,WAAW,CAAC;CACvB;AAYD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CA8B/D"}
package/dist/schema.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Options schema for `@dogsbay/plugin-ask`.
3
+ *
4
+ * Hand-rolled validation (no Zod dep), matching the convention in
5
+ * `@dogsbay/plugin-image-zoom`. `parseOptions` returns a fully-resolved
6
+ * options object with every field set to either the user value or the
7
+ * documented default. `endpoint` and `site` are REQUIRED — the widget is
8
+ * useless without a backend to call and a site id to route by.
9
+ */
10
+ const DEFAULTS = {
11
+ mode: "answer",
12
+ buttonLabel: "Ask AI",
13
+ title: "Ask the docs",
14
+ placeholder: "Ask a question…",
15
+ position: "bottom-right",
16
+ };
17
+ const PKG = "@dogsbay/plugin-ask";
18
+ /**
19
+ * Validate + resolve user options. Throws structured errors naming the
20
+ * offending field so users see a clear message rather than a stack trace.
21
+ */
22
+ export function parseOptions(input) {
23
+ if (input === undefined || input === null || typeof input !== "object" || Array.isArray(input)) {
24
+ throw new Error(`${PKG}: options are required and must be an object with at least { endpoint, site }, got ${describe(input)}.`);
25
+ }
26
+ const opts = input;
27
+ const endpoint = requireUrl(opts.endpoint, "endpoint");
28
+ const site = requireNonEmptyString(opts.site, "site");
29
+ const out = { ...DEFAULTS, endpoint, site };
30
+ if (opts.mode !== undefined) {
31
+ if (opts.mode !== "answer" && opts.mode !== "search") {
32
+ throw new Error(`${PKG}: options.mode must be "answer" or "search".`);
33
+ }
34
+ out.mode = opts.mode;
35
+ }
36
+ if (opts.buttonLabel !== undefined)
37
+ out.buttonLabel = requireNonEmptyString(opts.buttonLabel, "buttonLabel");
38
+ if (opts.title !== undefined)
39
+ out.title = requireNonEmptyString(opts.title, "title");
40
+ if (opts.placeholder !== undefined)
41
+ out.placeholder = requireNonEmptyString(opts.placeholder, "placeholder");
42
+ if (opts.position !== undefined) {
43
+ if (opts.position !== "bottom-right" && opts.position !== "bottom-left") {
44
+ throw new Error(`${PKG}: options.position must be "bottom-right" or "bottom-left".`);
45
+ }
46
+ out.position = opts.position;
47
+ }
48
+ return out;
49
+ }
50
+ function requireNonEmptyString(value, field) {
51
+ if (typeof value !== "string" || value.trim() === "") {
52
+ throw new Error(`${PKG}: options.${field} must be a non-empty string.`);
53
+ }
54
+ return value;
55
+ }
56
+ function requireUrl(value, field) {
57
+ const s = requireNonEmptyString(value, field);
58
+ try {
59
+ // eslint-disable-next-line no-new
60
+ new URL(s);
61
+ }
62
+ catch {
63
+ throw new Error(`${PKG}: options.${field} must be a valid absolute URL, got "${s}".`);
64
+ }
65
+ return s;
66
+ }
67
+ function describe(value) {
68
+ if (value === null)
69
+ return "null";
70
+ if (Array.isArray(value))
71
+ return "array";
72
+ return typeof value;
73
+ }
74
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@dogsbay/plugin-ask",
3
+ "version": "0.2.0-beta.81",
4
+ "description": "Floating \"Ask AI\" widget for Dogsbay docs sites — calls a shared AI Search proxy (dogsbay-ask).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./client": "./dist/client.js",
14
+ "./styles.css": "./src/styles.css"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src/styles.css",
19
+ "src/client.ts",
20
+ "skills",
21
+ "README.md"
22
+ ],
23
+ "dependencies": {
24
+ "@dogsbay/types": "0.2.0-beta.81"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.7.0",
28
+ "vitest": "^3.0.0",
29
+ "@types/node": "^22.0.0"
30
+ },
31
+ "license": "MIT",
32
+ "keywords": [
33
+ "dogsbay",
34
+ "dogsbay-plugin",
35
+ "documentation",
36
+ "ai-search",
37
+ "rag",
38
+ "ask-ai"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/dogsbay/dogsbay.git",
43
+ "directory": "packages/plugin-ask"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
49
+ }
50
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: plugin-ask-config
3
+ description: Configure the @dogsbay/plugin-ask "Ask AI" widget — endpoint, site id, and presentation options — in dogsbay.config.yml. Use when adding or debugging the Ask AI button on a Dogsbay docs site.
4
+ ---
5
+
6
+ # Configuring @dogsbay/plugin-ask
7
+
8
+ `@dogsbay/plugin-ask` adds a floating "Ask AI" button to every page. It sends
9
+ the question to a configured backend and shows the answer, so the docs site
10
+ stays static and holds no secrets.
11
+
12
+ ## Enable it
13
+
14
+ ```yaml
15
+ # dogsbay.config.yml
16
+ plugins:
17
+ - name: "@dogsbay/plugin-ask"
18
+ options:
19
+ endpoint: "https://<your-ask-endpoint>"
20
+ site: "openshift-docs-dogsbay"
21
+ ```
22
+
23
+ - **`endpoint`** (required) — URL of the backend the widget calls.
24
+ - **`site`** (required) — the site id sent with each request so the backend
25
+ knows which corpus to answer from. Conventionally the site's base path /
26
+ GitHub Pages subfolder, e.g. `tigera-dogsbay`. The team operating the backend
27
+ owns the `site` → corpus mapping; use the value they give you.
28
+
29
+ Optional presentation: `mode` (`answer` | `search`), `buttonLabel`, `title`,
30
+ `placeholder`, `position` (`bottom-right` | `bottom-left`).
31
+
32
+ ## When it isn't working
33
+
34
+ 1. **Button not appearing** — confirm the plugin is listed under `plugins:` and
35
+ that `dogsbay site build` ran; the widget mounts via a client module on every
36
+ page.
37
+ 2. **Errors when asking** — confirm `endpoint` is reachable from the browser and
38
+ that `site` matches the value the backend expects.
39
+ 3. **Empty or "I don't know" answers** — the backend's corpus may not be ready;
40
+ check with whoever operates the endpoint.
41
+
42
+ ## Styling
43
+
44
+ Styles use Dogsbay theme tokens (`--color-accent`, `--color-surface`,
45
+ `--radius`, …) with fallbacks, so the widget picks up the site theme. Override
46
+ the `.dba-*` classes in your own CSS to customize further.
package/src/client.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Client runtime for `@dogsbay/plugin-ask`.
3
+ *
4
+ * Imported via the auto-generated `src/lib/plugin-runtime.ts` in the user's
5
+ * Astro project, so it runs for side effects on every page. It injects a
6
+ * floating button + <dialog> at <body> level (outside the article), and on
7
+ * submit streams an answer from the dogsbay-ask proxy.
8
+ *
9
+ * Astro view transitions: the document is swapped on navigation, so we mount
10
+ * on `astro:page-load` and tear down on `astro:before-swap` to avoid stale
11
+ * nodes / duplicate buttons.
12
+ */
13
+ // @ts-expect-error: virtual module resolved by the Dogsbay Vite alias at build.
14
+ import config from "virtual:dogsbay-plugin-config/@dogsbay/plugin-ask";
15
+ import { buildAskRequest, extractText } from "./protocol.js";
16
+ import type { AskMode, AskPosition } from "./schema.js";
17
+
18
+ interface RuntimeConfig {
19
+ endpoint: string;
20
+ site: string;
21
+ mode: AskMode;
22
+ buttonLabel: string;
23
+ title: string;
24
+ placeholder: string;
25
+ position: AskPosition;
26
+ }
27
+
28
+ const cfg = config as RuntimeConfig;
29
+
30
+ const ROOT_ID = "dba-root";
31
+
32
+ function mount(): void {
33
+ if (typeof document === "undefined") return;
34
+ if (document.getElementById(ROOT_ID)) return; // already mounted
35
+
36
+ const root = document.createElement("div");
37
+ root.id = ROOT_ID;
38
+ root.dataset.position = cfg.position;
39
+
40
+ const button = document.createElement("button");
41
+ button.type = "button";
42
+ button.className = "dba-trigger";
43
+ button.textContent = cfg.buttonLabel;
44
+ button.setAttribute("aria-haspopup", "dialog");
45
+
46
+ const dialog = document.createElement("dialog");
47
+ dialog.className = "dba-dialog";
48
+ dialog.setAttribute("aria-label", cfg.title);
49
+ dialog.innerHTML = `
50
+ <form class="dba-form" method="dialog">
51
+ <header class="dba-header">
52
+ <h2 class="dba-title"></h2>
53
+ <button type="button" class="dba-close" aria-label="Close">×</button>
54
+ </header>
55
+ <div class="dba-inputrow">
56
+ <input class="dba-input" type="text" autocomplete="off" enterkeyhint="search" />
57
+ <button type="submit" class="dba-submit">Ask</button>
58
+ </div>
59
+ <div class="dba-answer" aria-live="polite" tabindex="-1"></div>
60
+ </form>
61
+ `;
62
+
63
+ // textContent (not innerHTML) for user-configurable strings — no injection.
64
+ dialog.querySelector<HTMLHeadingElement>(".dba-title")!.textContent = cfg.title;
65
+ const input = dialog.querySelector<HTMLInputElement>(".dba-input")!;
66
+ input.placeholder = cfg.placeholder;
67
+ const answer = dialog.querySelector<HTMLDivElement>(".dba-answer")!;
68
+ const form = dialog.querySelector<HTMLFormElement>(".dba-form")!;
69
+ const submit = dialog.querySelector<HTMLButtonElement>(".dba-submit")!;
70
+
71
+ button.addEventListener("click", () => {
72
+ dialog.showModal();
73
+ input.focus();
74
+ });
75
+ dialog.querySelector<HTMLButtonElement>(".dba-close")!.addEventListener("click", () => dialog.close());
76
+
77
+ form.addEventListener("submit", async (e) => {
78
+ e.preventDefault();
79
+ const query = input.value.trim();
80
+ if (!query) return;
81
+
82
+ submit.disabled = true;
83
+ answer.dataset.state = "loading";
84
+ answer.textContent = "…";
85
+
86
+ try {
87
+ const res = await fetch(cfg.endpoint, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify(buildAskRequest(cfg.site, query, cfg.mode)),
91
+ });
92
+ if (!res.ok || !res.body) {
93
+ throw new Error(`Request failed (${res.status})`);
94
+ }
95
+ answer.textContent = "";
96
+ answer.dataset.state = "streaming";
97
+ const reader = res.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buf = "";
100
+ for (;;) {
101
+ const { value, done } = await reader.read();
102
+ if (done) break;
103
+ buf += decoder.decode(value, { stream: true });
104
+ // Only process complete lines; keep any partial line buffered so a
105
+ // `data:` frame split across reads isn't parsed half-formed.
106
+ const nl = buf.lastIndexOf("\n");
107
+ if (nl >= 0) {
108
+ answer.textContent += extractText(buf.slice(0, nl + 1));
109
+ buf = buf.slice(nl + 1);
110
+ }
111
+ }
112
+ if (buf) answer.textContent += extractText(buf);
113
+ answer.dataset.state = "done";
114
+ } catch (err) {
115
+ answer.dataset.state = "error";
116
+ answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
117
+ } finally {
118
+ submit.disabled = false;
119
+ }
120
+ });
121
+
122
+ root.append(button, dialog);
123
+ document.body.appendChild(root);
124
+ }
125
+
126
+ function unmount(): void {
127
+ if (typeof document === "undefined") return;
128
+ document.getElementById(ROOT_ID)?.remove();
129
+ }
130
+
131
+ if (typeof document !== "undefined") {
132
+ if (document.readyState === "loading") {
133
+ document.addEventListener("DOMContentLoaded", mount);
134
+ } else {
135
+ mount();
136
+ }
137
+ document.addEventListener("astro:page-load", mount);
138
+ document.addEventListener("astro:before-swap", unmount);
139
+ }
package/src/styles.css ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Styles for @dogsbay/plugin-ask. Uses Dogsbay theme tokens where available
3
+ * with safe fallbacks, so it themes automatically but never breaks if a token
4
+ * is missing. All selectors are namespaced under .dba- to avoid collisions.
5
+ */
6
+
7
+ #dba-root {
8
+ --dba-accent: var(--color-accent, #2563eb);
9
+ --dba-accent-contrast: var(--color-accent-contrast, #ffffff);
10
+ --dba-surface: var(--color-surface, #ffffff);
11
+ --dba-text: var(--color-text, #1f2937);
12
+ --dba-border: var(--color-border, #e5e7eb);
13
+ --dba-radius: var(--radius, 0.5rem);
14
+ }
15
+
16
+ .dba-trigger {
17
+ position: fixed;
18
+ bottom: 1.25rem;
19
+ z-index: 60;
20
+ padding: 0.625rem 1rem;
21
+ font: inherit;
22
+ font-weight: 600;
23
+ color: var(--dba-accent-contrast);
24
+ background: var(--dba-accent);
25
+ border: none;
26
+ border-radius: 999px;
27
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
28
+ cursor: pointer;
29
+ }
30
+ #dba-root[data-position="bottom-right"] .dba-trigger { right: 1.25rem; }
31
+ #dba-root[data-position="bottom-left"] .dba-trigger { left: 1.25rem; }
32
+ .dba-trigger:hover { filter: brightness(1.05); }
33
+ .dba-trigger:focus-visible { outline: 2px solid var(--dba-accent); outline-offset: 2px; }
34
+
35
+ .dba-dialog {
36
+ width: min(40rem, 92vw);
37
+ max-height: 80vh;
38
+ padding: 0;
39
+ color: var(--dba-text);
40
+ background: var(--dba-surface);
41
+ border: 1px solid var(--dba-border);
42
+ border-radius: var(--dba-radius);
43
+ box-shadow: 0 10px 40px rgb(0 0 0 / 0.25);
44
+ }
45
+ .dba-dialog::backdrop { background: rgb(0 0 0 / 0.4); }
46
+
47
+ .dba-form { display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem 1.25rem 1.25rem; }
48
+ .dba-header { display: flex; align-items: center; justify-content: space-between; }
49
+ .dba-title { margin: 0; font-size: 1.05rem; }
50
+ .dba-close {
51
+ font-size: 1.5rem; line-height: 1; padding: 0 0.25rem;
52
+ color: inherit; background: none; border: none; cursor: pointer; opacity: 0.6;
53
+ }
54
+ .dba-close:hover { opacity: 1; }
55
+
56
+ .dba-inputrow { display: flex; gap: 0.5rem; }
57
+ .dba-input {
58
+ flex: 1; padding: 0.55rem 0.75rem; font: inherit;
59
+ color: var(--dba-text); background: var(--dba-surface);
60
+ border: 1px solid var(--dba-border); border-radius: var(--dba-radius);
61
+ }
62
+ .dba-input:focus-visible { outline: 2px solid var(--dba-accent); outline-offset: 1px; }
63
+ .dba-submit {
64
+ padding: 0.55rem 1rem; font: inherit; font-weight: 600;
65
+ color: var(--dba-accent-contrast); background: var(--dba-accent);
66
+ border: none; border-radius: var(--dba-radius); cursor: pointer;
67
+ }
68
+ .dba-submit:disabled { opacity: 0.6; cursor: progress; }
69
+
70
+ .dba-answer {
71
+ min-height: 2rem; max-height: 50vh; overflow-y: auto;
72
+ padding: 0.75rem; white-space: pre-wrap; line-height: 1.55;
73
+ background: color-mix(in srgb, var(--dba-text) 4%, transparent);
74
+ border-radius: var(--dba-radius);
75
+ }
76
+ .dba-answer[data-state="loading"] { opacity: 0.6; }
77
+ .dba-answer[data-state="error"] { color: #b91c1c; }
78
+
79
+ @media (prefers-reduced-motion: no-preference) {
80
+ .dba-dialog[open] { animation: dba-in 0.12s ease-out; }
81
+ }
82
+ @keyframes dba-in { from { transform: translateY(6px); opacity: 0; } to { transform: none; opacity: 1; } }