@bdocs/plugin-ask-ai 0.3.0 → 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.
@@ -1,36 +1,31 @@
1
+ import { a as createAdapterHeaders, c as checkInputSafety, d as buildUserPrompt, n as rateLimit, r as pickClientContext, s as DEFAULT_DENY_PATTERNS } from "../rate-limit-eZ04302C.mjs";
1
2
  import OpenAI from "openai";
3
+ //#region src/server/errors.ts
4
+ const AI_NOT_CONFIGURED = "AI_NOT_CONFIGURED";
5
+ const AI_PROVIDER_ERROR = "AI_PROVIDER_ERROR";
6
+ const AI_TIMEOUT = "AI_TIMEOUT";
7
+ const ADAPTER_ERROR = "ADAPTER_ERROR";
8
+ /** Never return arbitrary upstream/parser messages to an untrusted client. */
9
+ function toPublicProviderError() {
10
+ return AI_PROVIDER_ERROR;
11
+ }
12
+ //#endregion
2
13
  //#region src/server/handler.ts
3
14
  const STREAM_TIMEOUT_MS = 6e4;
4
- const DOCS_START = "<<<DOCS_START>>>";
5
- const DOCS_END = "<<<DOCS_END>>>";
6
- function escapeDocsMarkers(s) {
7
- return s.replace(/<<<DOCS_(START|END)>>>/g, "<DOCS_$1>");
8
- }
9
- function buildUserPrompt(question, context) {
10
- if (!context || !context.content) return `${DOCS_START}\n(no documentation page in scope — reply "Not in docs." for any Boltdocs question)\n${DOCS_END}\n\nUser Question: ${question}`;
11
- return [
12
- DOCS_START,
13
- `[Page: ${context.page}]`,
14
- escapeDocsMarkers(context.content),
15
- DOCS_END,
16
- "",
17
- `User Question: ${question}`
18
- ].join("\n");
19
- }
20
15
  async function streamLLMResponse(options, onEvent) {
21
- const { model, systemPrompt, question, context, maxOutputTokens, baseURL, env, signal, provider = "openai", providerEnvKey = "OPENAI_API_KEY", devMode = false } = options;
16
+ const { model, systemPrompt, question, context, maxOutputTokens, baseURL, env, signal, provider = "openai", providerEnvKey = "OPENAI_API_KEY", devMode = false, temperature = .3, topP = 1 } = options;
22
17
  const apiKey = env[providerEnvKey];
23
18
  if (!apiKey) {
24
19
  onEvent({
25
20
  type: "error",
26
- data: `${providerEnvKey} is not set in the server environment.`
21
+ data: AI_NOT_CONFIGURED
27
22
  });
28
23
  return;
29
24
  }
30
25
  const userPrompt = buildUserPrompt(question, context);
31
26
  const openai = new OpenAI({
32
27
  apiKey,
33
- baseURL: baseURL || env.OPENAI_BASE_URL || void 0
28
+ baseURL: baseURL || (provider === "openai" ? env.OPENAI_BASE_URL : void 0)
34
29
  });
35
30
  const timeoutController = new AbortController();
36
31
  const combinedController = new AbortController();
@@ -38,7 +33,9 @@ async function streamLLMResponse(options, onEvent) {
38
33
  const onExternalAbort = () => {
39
34
  if (!combinedController.signal.aborted) combinedController.abort();
40
35
  };
36
+ let timedOut = false;
41
37
  const onTimeoutAbort = () => {
38
+ timedOut = true;
42
39
  if (!combinedController.signal.aborted) combinedController.abort();
43
40
  };
44
41
  if (externalSignal) if (externalSignal.aborted) onExternalAbort();
@@ -50,6 +47,8 @@ async function streamLLMResponse(options, onEvent) {
50
47
  const stream = await openai.chat.completions.create({
51
48
  model,
52
49
  max_tokens: maxOutputTokens,
50
+ temperature,
51
+ top_p: topP,
53
52
  stream: true,
54
53
  stream_options: { include_usage: true },
55
54
  messages: [{
@@ -75,6 +74,13 @@ async function streamLLMResponse(options, onEvent) {
75
74
  completionTokens = usage.completion_tokens ?? completionTokens;
76
75
  }
77
76
  }
77
+ if (timedOut) {
78
+ onEvent({
79
+ type: "error",
80
+ data: AI_TIMEOUT
81
+ });
82
+ return;
83
+ }
78
84
  if (devMode && (promptTokens > 0 || completionTokens > 0)) onEvent({
79
85
  type: "usage",
80
86
  data: {
@@ -87,11 +93,11 @@ async function streamLLMResponse(options, onEvent) {
87
93
  }
88
94
  });
89
95
  if (!externalSignal?.aborted && !combinedController.signal.aborted) onEvent({ type: "done" });
90
- } catch (err) {
91
- if (externalSignal?.aborted || combinedController.signal.aborted) return;
96
+ } catch {
97
+ if (externalSignal?.aborted) return;
92
98
  onEvent({
93
99
  type: "error",
94
- data: err instanceof Error ? err.message : "OpenAI request failed"
100
+ data: timedOut ? AI_TIMEOUT : toPublicProviderError()
95
101
  });
96
102
  } finally {
97
103
  clearTimeout(timeoutId);
@@ -100,4 +106,315 @@ async function streamLLMResponse(options, onEvent) {
100
106
  }
101
107
  }
102
108
  //#endregion
103
- export { streamLLMResponse };
109
+ //#region src/server/adapters/sse.ts
110
+ function eventToSse(event) {
111
+ switch (event.type) {
112
+ case "context": return `data: ${JSON.stringify({ context: event.data })}\n\n`;
113
+ case "text": return `data: ${JSON.stringify({ text: event.data })}\n\n`;
114
+ case "usage": return `data: ${JSON.stringify({ usage: event.data })}\n\n`;
115
+ case "error": return `data: ${JSON.stringify({ error: event.data })}\n\n`;
116
+ case "done": return "";
117
+ }
118
+ }
119
+ const DONE_SSE = "data: [DONE]\n\n";
120
+ //#endregion
121
+ //#region src/server/adapters/security.ts
122
+ function isAuthorized(config, headers) {
123
+ if (!config.secretKey) return true;
124
+ return headers.get("x-boltdocs-ask-ai-key") === config.secretKey;
125
+ }
126
+ function validateClientContext(config, body, headers) {
127
+ if (!body || typeof body !== "object" || !("context" in body)) return null;
128
+ if (config.allowClientContext || Boolean(config.secretKey) && isAuthorized(config, headers)) return null;
129
+ return "CLIENT_CONTEXT_NOT_ALLOWED";
130
+ }
131
+ function validateQuestion(config, body) {
132
+ const question = body && typeof body === "object" && "question" in body ? body.question : void 0;
133
+ const safety = checkInputSafety(typeof question === "string" ? question : "", config.maxInputChars ?? 2e3, DEFAULT_DENY_PATTERNS);
134
+ return safety.ok ? null : safety.reason;
135
+ }
136
+ function checkAdapterRateLimit(config, ip) {
137
+ return rateLimit(ip, config.rateLimitPerMinute ?? 30);
138
+ }
139
+ //#endregion
140
+ //#region src/server/adapters/stream-options.ts
141
+ function createStreamOptions(config, question, context, env, signal) {
142
+ const provider = config.provider ?? "openai";
143
+ const providerEnvKey = config.providerEnvKey ?? "OPENAI_API_KEY";
144
+ const providerEnv = {
145
+ [providerEnvKey]: env[providerEnvKey],
146
+ OPENAI_BASE_URL: env.OPENAI_BASE_URL
147
+ };
148
+ return {
149
+ model: config.model,
150
+ systemPrompt: config.systemPrompt,
151
+ question,
152
+ context,
153
+ maxOutputTokens: config.maxOutputTokens ?? 600,
154
+ baseURL: config.baseURL,
155
+ env: providerEnv,
156
+ provider,
157
+ providerEnvKey,
158
+ signal,
159
+ devMode: config.devMode ?? false,
160
+ temperature: config.temperature,
161
+ topP: config.topP
162
+ };
163
+ }
164
+ //#endregion
165
+ //#region src/server/adapters/aws.ts
166
+ async function handleAwsAskAi(event, config, env = process.env) {
167
+ const requestHeaders = new Headers(event.headers ?? {});
168
+ const responseHeaders = createAdapterHeaders(config, requestHeaders.get("origin"));
169
+ if (event.httpMethod === "OPTIONS") return {
170
+ statusCode: 200,
171
+ headers: responseHeaders,
172
+ body: ""
173
+ };
174
+ if (event.httpMethod !== "POST") return {
175
+ statusCode: 405,
176
+ headers: responseHeaders,
177
+ body: JSON.stringify({ error: "Method Not Allowed" })
178
+ };
179
+ try {
180
+ const bodyText = event.isBase64Encoded ? Buffer.from(event.body ?? "", "base64").toString("utf8") : event.body ?? "";
181
+ if (new TextEncoder().encode(bodyText).byteLength > (config.maxRequestBytes ?? 65536)) return {
182
+ statusCode: 413,
183
+ headers: responseHeaders,
184
+ body: JSON.stringify({ error: "REQUEST_TOO_LARGE" })
185
+ };
186
+ const payload = bodyText ? JSON.parse(bodyText) : {};
187
+ if (!isAuthorized(config, requestHeaders)) return {
188
+ statusCode: 401,
189
+ headers: responseHeaders,
190
+ body: JSON.stringify({ error: "UNAUTHORIZED" })
191
+ };
192
+ const contextError = validateClientContext(config, payload, requestHeaders);
193
+ if (contextError) return {
194
+ statusCode: 403,
195
+ headers: responseHeaders,
196
+ body: JSON.stringify({ error: contextError })
197
+ };
198
+ const questionError = validateQuestion(config, payload);
199
+ if (questionError) return {
200
+ statusCode: 400,
201
+ headers: responseHeaders,
202
+ body: JSON.stringify({ error: questionError })
203
+ };
204
+ const rate = checkAdapterRateLimit(config, requestHeaders.get("x-forwarded-for") || "unknown");
205
+ if (!rate.ok) return {
206
+ statusCode: 429,
207
+ headers: {
208
+ ...responseHeaders,
209
+ "Retry-After": String(rate.retryAfter)
210
+ },
211
+ body: JSON.stringify({ error: "RATE_LIMITED" })
212
+ };
213
+ const question = payload.question;
214
+ const context = pickClientContext(payload, config.contextChars ?? 6e3);
215
+ const parts = [];
216
+ await streamLLMResponse(createStreamOptions(config, question, context, env), (event) => {
217
+ const sse = eventToSse(event);
218
+ if (sse) parts.push(sse);
219
+ });
220
+ parts.push(DONE_SSE);
221
+ return {
222
+ statusCode: 200,
223
+ headers: responseHeaders,
224
+ body: parts.join("")
225
+ };
226
+ } catch {
227
+ return {
228
+ statusCode: 500,
229
+ headers: responseHeaders,
230
+ body: `data: ${JSON.stringify({ error: ADAPTER_ERROR })}\n\n${DONE_SSE}`
231
+ };
232
+ }
233
+ }
234
+ //#endregion
235
+ //#region src/server/adapters/netlify.ts
236
+ async function handleNetlifyAskAi(event, config, env = process.env) {
237
+ const requestHeaders = new Headers(event.headers ?? {});
238
+ const responseHeaders = createAdapterHeaders(config, requestHeaders.get("origin"));
239
+ if (event.httpMethod === "OPTIONS") return {
240
+ statusCode: 200,
241
+ headers: responseHeaders,
242
+ body: ""
243
+ };
244
+ if (event.httpMethod !== "POST") return {
245
+ statusCode: 405,
246
+ headers: responseHeaders,
247
+ body: JSON.stringify({ error: "Method Not Allowed" })
248
+ };
249
+ try {
250
+ const bodyText = event.body ?? "";
251
+ if (new TextEncoder().encode(bodyText).byteLength > (config.maxRequestBytes ?? 65536)) return {
252
+ statusCode: 413,
253
+ headers: responseHeaders,
254
+ body: JSON.stringify({ error: "REQUEST_TOO_LARGE" })
255
+ };
256
+ const payload = bodyText ? JSON.parse(bodyText) : {};
257
+ if (!isAuthorized(config, requestHeaders)) return {
258
+ statusCode: 401,
259
+ headers: responseHeaders,
260
+ body: JSON.stringify({ error: "UNAUTHORIZED" })
261
+ };
262
+ const contextError = validateClientContext(config, payload, requestHeaders);
263
+ if (contextError) return {
264
+ statusCode: 403,
265
+ headers: responseHeaders,
266
+ body: JSON.stringify({ error: contextError })
267
+ };
268
+ const questionError = validateQuestion(config, payload);
269
+ if (questionError) return {
270
+ statusCode: 400,
271
+ headers: responseHeaders,
272
+ body: JSON.stringify({ error: questionError })
273
+ };
274
+ const rate = checkAdapterRateLimit(config, requestHeaders.get("x-forwarded-for") || "unknown");
275
+ if (!rate.ok) return {
276
+ statusCode: 429,
277
+ headers: {
278
+ ...responseHeaders,
279
+ "Retry-After": String(rate.retryAfter)
280
+ },
281
+ body: JSON.stringify({ error: "RATE_LIMITED" })
282
+ };
283
+ const question = payload.question;
284
+ const context = pickClientContext(payload, config.contextChars ?? 6e3);
285
+ const parts = [];
286
+ await streamLLMResponse(createStreamOptions(config, question, context, env), (event) => {
287
+ const sse = eventToSse(event);
288
+ if (sse) parts.push(sse);
289
+ });
290
+ parts.push(DONE_SSE);
291
+ return {
292
+ statusCode: 200,
293
+ headers: responseHeaders,
294
+ body: parts.join("")
295
+ };
296
+ } catch {
297
+ return {
298
+ statusCode: 500,
299
+ headers: responseHeaders,
300
+ body: `data: ${JSON.stringify({ error: ADAPTER_ERROR })}\n\n${DONE_SSE}`
301
+ };
302
+ }
303
+ }
304
+ //#endregion
305
+ //#region src/server/adapters/vercel.ts
306
+ async function handleVercelAskAi(req, res, config, env = process.env) {
307
+ const requestHeaders = new Headers(req.headers ?? {});
308
+ const responseHeaders = createAdapterHeaders(config, requestHeaders.get("origin"));
309
+ for (const [key, value] of Object.entries(responseHeaders)) res.setHeader(key, value);
310
+ if (req.method === "OPTIONS") {
311
+ res.status(200).end();
312
+ return;
313
+ }
314
+ if (req.method !== "POST") {
315
+ res.status(405).end("Method Not Allowed");
316
+ return;
317
+ }
318
+ try {
319
+ if (!isAuthorized(config, requestHeaders)) {
320
+ res.status(401).json({ error: "UNAUTHORIZED" });
321
+ return;
322
+ }
323
+ const contextError = validateClientContext(config, req.body, requestHeaders);
324
+ if (contextError) {
325
+ res.status(403).json({ error: contextError });
326
+ return;
327
+ }
328
+ const questionError = validateQuestion(config, req.body);
329
+ if (questionError) {
330
+ res.status(400).json({ error: questionError });
331
+ return;
332
+ }
333
+ const rate = checkAdapterRateLimit(config, requestHeaders.get("x-forwarded-for") || "unknown");
334
+ if (!rate.ok) {
335
+ res.setHeader("Retry-After", String(rate.retryAfter));
336
+ res.status(429).json({ error: "RATE_LIMITED" });
337
+ return;
338
+ }
339
+ const question = req.body.question;
340
+ await streamLLMResponse(createStreamOptions(config, question, pickClientContext(req.body, config.contextChars ?? 6e3), env), (event) => {
341
+ const sse = eventToSse(event);
342
+ if (sse) res.write(sse);
343
+ });
344
+ res.write("data: [DONE]\n\n");
345
+ res.end();
346
+ } catch {
347
+ res.write(`data: ${JSON.stringify({ error: ADAPTER_ERROR })}\n\ndata: [DONE]\n\n`);
348
+ res.end();
349
+ }
350
+ }
351
+ //#endregion
352
+ //#region src/server/adapters/web.ts
353
+ async function handleWebAskAi(request, config, env = process.env) {
354
+ const corsHeaders = createAdapterHeaders(config, request.headers.get("origin"));
355
+ if (request.method === "OPTIONS") return new Response(null, {
356
+ status: 200,
357
+ headers: corsHeaders
358
+ });
359
+ if (request.method !== "POST") return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
360
+ status: 405,
361
+ headers: corsHeaders
362
+ });
363
+ try {
364
+ const bodyText = await request.text();
365
+ if (new TextEncoder().encode(bodyText).byteLength > (config.maxRequestBytes ?? 65536)) return new Response(JSON.stringify({ error: "REQUEST_TOO_LARGE" }), {
366
+ status: 413,
367
+ headers: corsHeaders
368
+ });
369
+ const payload = bodyText ? JSON.parse(bodyText) : {};
370
+ if (!isAuthorized(config, request.headers)) return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
371
+ status: 401,
372
+ headers: corsHeaders
373
+ });
374
+ const contextError = validateClientContext(config, payload, request.headers);
375
+ if (contextError) return new Response(JSON.stringify({ error: contextError }), {
376
+ status: 403,
377
+ headers: corsHeaders
378
+ });
379
+ const questionError = validateQuestion(config, payload);
380
+ if (questionError) return new Response(JSON.stringify({ error: questionError }), {
381
+ status: 400,
382
+ headers: corsHeaders
383
+ });
384
+ const rate = checkAdapterRateLimit(config, request.headers.get("x-forwarded-for") || "unknown");
385
+ if (!rate.ok) {
386
+ corsHeaders["Retry-After"] = String(rate.retryAfter);
387
+ return new Response(JSON.stringify({ error: "RATE_LIMITED" }), {
388
+ status: 429,
389
+ headers: corsHeaders
390
+ });
391
+ }
392
+ const question = payload.question;
393
+ const context = pickClientContext(payload, config.contextChars ?? 6e3);
394
+ const encoder = new TextEncoder();
395
+ const stream = new ReadableStream({ async start(controller) {
396
+ try {
397
+ await streamLLMResponse(createStreamOptions(config, question, context, env, request.signal), (event) => {
398
+ const sse = eventToSse(event);
399
+ if (sse) controller.enqueue(encoder.encode(sse));
400
+ });
401
+ controller.enqueue(encoder.encode(DONE_SSE));
402
+ } catch {
403
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: ADAPTER_ERROR })}\n\n`));
404
+ } finally {
405
+ controller.close();
406
+ }
407
+ } });
408
+ return new Response(stream, {
409
+ status: 200,
410
+ headers: corsHeaders
411
+ });
412
+ } catch {
413
+ return new Response(`data: ${JSON.stringify({ error: ADAPTER_ERROR })}\n\n${DONE_SSE}`, {
414
+ status: 500,
415
+ headers: corsHeaders
416
+ });
417
+ }
418
+ }
419
+ //#endregion
420
+ export { handleAwsAskAi, handleNetlifyAskAi, handleVercelAskAi, handleWebAskAi, streamLLMResponse };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bdocs/plugin-ask-ai",
3
- "version": "0.3.0",
4
- "description": "Context-aware AI assistant querying plugin for Boltdocs. OpenAI-powered, page-scoped, streaming. Ships the `<AskAiBubble />` and `<AskAiDialog />` client components for manual composition in custom layouts.",
3
+ "version": "0.4.0",
4
+ "description": "Secure, page-scoped streaming AI assistant for Boltdocs with provider presets, hardened server adapters, automatic floating UI, and customizable client components.",
5
5
  "files": [
6
6
  "dist"
7
7
  ],
@@ -23,25 +23,32 @@
23
23
  "license": "MIT",
24
24
  "type": "module",
25
25
  "dependencies": {
26
- "@bdocs/dui": "^0.3.0",
26
+ "@bdocs/dui": "^0.6.0",
27
+ "clsx": "2.1.1",
27
28
  "openai": "^4.77.0",
28
29
  "streamdown": "^1.0.0",
30
+ "tailwind-merge": "3.5.0",
29
31
  "zod": "^3.23.0"
30
32
  },
31
33
  "peerDependencies": {
34
+ "boltdocs": ">=3.3.0",
32
35
  "react": "^19.0.0",
33
36
  "react-dom": "^19.0.0",
34
- "vite": "^7.0.0 || ^8.0.0"
37
+ "vite": "^8.1.0"
35
38
  },
36
39
  "devDependencies": {
40
+ "@testing-library/jest-dom": "^6.9.1",
41
+ "@testing-library/react": "^16.3.2",
37
42
  "@types/node": "^22.0.0",
38
43
  "@types/react": "^19.0.0",
39
44
  "@types/react-dom": "^19.0.0",
45
+ "jsdom": "^24.1.3",
40
46
  "react": "^19.0.0",
41
47
  "react-dom": "^19.0.0",
42
48
  "tsdown": "^0.21.7",
43
49
  "typescript": "^5.9.3",
44
- "boltdocs": "3.2.1"
50
+ "boltdocs": "3.4.0",
51
+ "tsdown-config": "1.0.0"
45
52
  },
46
53
  "scripts": {
47
54
  "build": "tsdown --config-loader unrun",