sagedesk 2.1.1 → 2.3.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.
package/README.md CHANGED
@@ -23,7 +23,9 @@ All embedding and semantic search runs entirely in the visitor's browser via Web
23
23
 
24
24
  ### LLM Mode
25
25
 
26
- The widget posts visitor queries to your own backend. Your backend handles embedding, retrieval, and LLM synthesis. The API key lives in your environment variables and never touches the browser. sagedesk provides ready-made server handlers for Next.js and Express - you own your entire stack.
26
+ The widget embeds the visitor's query in the browser (same WASM model as local mode), then posts `{ query, queryVector }` to your own backend. Your backend does retrieval against the prebuilt index and calls your LLM provider for synthesis. The API key lives in your environment variables and never touches the browser. sagedesk provides ready-made server handlers for Next.js and Express - you own your entire stack.
27
+
28
+ Because the embedder stays in the browser, the server function carries no native ONNX runtime and no model weights. It deploys cleanly on Vercel, AWS Lambda, and any other serverless platform with no special configuration - the built `sagedesk/server` bundle is under 10 KB.
27
29
 
28
30
  | | Local Mode | LLM Mode |
29
31
  |---|---|---|
@@ -38,6 +40,15 @@ The widget posts visitor queries to your own backend. Your backend handles embed
38
40
 
39
41
  ---
40
42
 
43
+ ## Framework Support
44
+
45
+ | | Supported |
46
+ |---|---|
47
+ | **Frontend** | Vanilla HTML/JS, React, Next.js (App Router) |
48
+ | **Backend** (LLM Mode) | Next.js API Routes, Express, any Node.js server, Vercel, AWS Lambda |
49
+
50
+ ---
51
+
41
52
  ## Installation
42
53
 
43
54
  ```bash
@@ -220,7 +231,7 @@ app.use('/api/sagedesk', createSageDeskMiddleware({
220
231
  }));
221
232
  ```
222
233
 
223
- > **Serverless & Vercel compatible.** The server handler uses a pure WebAssembly embedding backend with no native binary dependencies. It works out of the box on Vercel, AWS Lambda, and any other serverless platform no additional configuration required.
234
+ > **Serverless ready.** The handler does no embedding - it only reads the prebuilt index, runs an in-memory dot-product search, and proxies one HTTP call to your LLM provider. There is no `@huggingface/transformers` import, no `onnxruntime-node`, and no native binaries on the server, so no `next.config.js` workarounds, no `outputFileTracingIncludes`, and no Vercel function-size hacks are needed. Just import and mount the handler.
224
235
 
225
236
  ### Step 3 - Configure the widget
226
237
 
@@ -307,7 +318,6 @@ createSageDeskHandler({
307
318
  | `provider` | `string` | yes | Provider name (e.g., `'openai'`, `'anthropic'`) or full API base URL (e.g., `'https://api.example.com/v1'`). |
308
319
  | `apiKey` | `string` | yes | LLM API key (server-side only). |
309
320
  | `model` | `string` | yes | Model name passed to the provider. |
310
- | `embeddingModel` | `string` | no | Must match build-time model. Defaults to `all-MiniLM-L6-v2`. |
311
321
  | `topK` | `number` | no | Number of chunks retrieved for context. Defaults to `5`. |
312
322
  | `minScore` | `number` | no | Minimum similarity score for a chunk. Defaults to `0.42`. |
313
323
  | `systemPrompt` | `string` | no | Override the default system prompt sent to the LLM. |
@@ -325,41 +335,10 @@ sagedesk includes built-in resilience for LLM mode. If the LLM provider fails-wh
325
335
 
326
336
  2. **Automatic Fallback** - When an LLM request fails, the server returns the best matching knowledge chunks without synthesis. Visitors still get relevant, grounded information.
327
337
 
328
- 3. **Developer Transparency** - The browser console logs meaningful warnings for debugging:
329
- - `"[sagedesk] Support service authentication failed. Showing relevant knowledge instead."` - Invalid or expired API key
330
- - `"[sagedesk] Support service quota exhausted. Showing relevant knowledge instead."` - Rate limit hit
331
- - `"[sagedesk] Support service took too long to respond. Showing relevant knowledge instead."` - Timeout
332
- - `"[sagedesk] Support service error. Showing relevant knowledge instead."` - Generic API error
333
- - `"[sagedesk] Support service returned invalid response. Showing relevant knowledge instead."` - Malformed response
338
+ 3. **Developer Transparency** - The browser console logs a `[sagedesk]` warning for each failure class: auth error, quota exhausted, timeout, generic API error, and malformed response. Each message describes the cause and notes that the fallback was triggered.
334
339
 
335
340
  4. **User Experience** - Visitors always see a fallback message (configured via `agent.fallback` or `agent.fallbackPool`) alongside relevant knowledge chunks. No errors are exposed to users.
336
341
 
337
- ### Configuring Timeout
338
-
339
- Adjust the LLM request timeout based on your provider's typical response time:
340
-
341
- ```ts
342
- // Next.js
343
- export const POST = createSageDeskHandler({
344
- indexPath: './public/support-index.json',
345
- provider: 'deepseek',
346
- apiKey: process.env.SAGEDESK_LLM_API_KEY!,
347
- model: 'deepseek-chat',
348
- llmTimeoutMs: 8000, // 8 seconds
349
- });
350
- ```
351
-
352
- ```ts
353
- // Express
354
- app.use('/api/sagedesk', createSageDeskMiddleware({
355
- indexPath: './public/support-index.json',
356
- provider: 'openai',
357
- apiKey: process.env.SAGEDESK_LLM_API_KEY!,
358
- model: 'gpt-4o-mini',
359
- llmTimeoutMs: 10000, // 10 seconds
360
- }));
361
- ```
362
-
363
342
  ---
364
343
 
365
344
  ## Widget Configuration (`AgentConfig`)
@@ -370,7 +349,7 @@ Applies to both modes.
370
349
  |---|:---:|:---:|---|
371
350
  | `name` | `string` | **Required** | Display name in the chat header. |
372
351
  | `theme` | `classic`, `light`, `dark` | `classic` | Visual style of the widget. |
373
- | `model` | `string` | `all-MiniLM-L6-v2` | Embedding model. Must match build-time model. Local mode only. |
352
+ | `model` | `string` | `all-MiniLM-L6-v2` | Embedding model loaded by the browser. Must match the build-time model. Used by both modes - local mode embeds & searches in-browser; LLM mode embeds in-browser and posts the vector to your handler. |
374
353
  | `accentColor` | `string` | `#534AB7` | Hex color for primary UI elements. |
375
354
  | `greeting` | `string` | - | Initial message shown to visitors. |
376
355
  | `fallback` | `string` | - | Message shown when no answer is found. |
@@ -423,13 +402,13 @@ sagedesk defaults to `all-MiniLM-L6-v2` (~22MB), which offers an excellent balan
423
402
  | `paraphrase-multilingual-MiniLM-L12-v2` | 384 | ~45 MB | 50+ languages. |
424
403
  | `all-mpnet-base-v2` | 768 | ~85 MB | Maximum semantic quality. |
425
404
 
426
- > **Note:** The `--model` flag in `npx sagedesk build` must match the `agent.model` prop (local mode) or the `embeddingModel` option in your server handler (LLM mode).
405
+ > **Note:** The `--model` flag in `npx sagedesk build` must match the `agent.model` prop on the widget. Both local mode and LLM mode embed in the browser using `agent.model`, and the resulting vectors must live in the same space as the index built by the CLI.
427
406
 
428
407
  ---
429
408
 
430
409
  ## Browser Support
431
410
 
432
- Requires **WebAssembly** support (local mode only - LLM mode has no browser requirements beyond `fetch`).
411
+ Requires **WebAssembly** support. Both local and LLM mode embed visitor queries in-browser, so the same WASM-capable browsers are required for both. WASM is supported by all modern browsers (Chrome 57+, Firefox 52+, Safari 11+, Edge 16+).
433
412
 
434
413
  - Chrome 90+
435
414
  - Firefox 89+
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  // src/react/SageDeskWidget.tsx
4
- import {
4
+ import React, {
5
5
  useState as useState2,
6
6
  useRef as useRef2,
7
7
  useEffect as useEffect2,
8
- useCallback as useCallback2
8
+ useCallback as useCallback2,
9
+ useMemo as useMemo2
9
10
  } from "react";
10
11
  import { createPortal } from "react-dom";
11
12
 
@@ -265,6 +266,7 @@ function logFallbackWarning(reason) {
265
266
  }
266
267
  var initialState = {
267
268
  messages: [],
269
+ userMessages: [],
268
270
  isOpen: false,
269
271
  isTyping: false,
270
272
  engineStatus: "idle",
@@ -277,8 +279,11 @@ function reducer(state, action) {
277
279
  return { ...state, isOpen: true };
278
280
  case "CLOSE":
279
281
  return { ...state, isOpen: false };
280
- case "ADD_MESSAGE":
281
- return { ...state, messages: [...state.messages, action.payload] };
282
+ case "ADD_MESSAGE": {
283
+ const newMessages = [...state.messages, action.payload];
284
+ const newUserMessages = action.payload.role === "user" ? [...state.userMessages, action.payload] : state.userMessages;
285
+ return { ...state, messages: newMessages, userMessages: newUserMessages };
286
+ }
282
287
  case "SET_TYPING":
283
288
  return { ...state, isTyping: action.payload };
284
289
  case "SET_ENGINE_STATUS":
@@ -301,6 +306,7 @@ function useSageDesk(config) {
301
306
  const embedderRef = useRef(null);
302
307
  const engineStartedRef = useRef(false);
303
308
  const msgCounterRef = useRef(0);
309
+ const engineReadyCallbacksRef = useRef([]);
304
310
  const [chips, setChips] = useState([]);
305
311
  const makeId = () => `msg-${++msgCounterRef.current}`;
306
312
  const addMessage = useCallback(
@@ -312,12 +318,27 @@ function useSageDesk(config) {
312
318
  },
313
319
  []
314
320
  );
321
+ const notifyEngineReady = useCallback(() => {
322
+ const callbacks = engineReadyCallbacksRef.current;
323
+ engineReadyCallbacksRef.current = [];
324
+ callbacks.forEach((cb) => cb());
325
+ }, []);
315
326
  const startEngine = useCallback(async () => {
316
327
  if (engineStartedRef.current) return;
317
328
  engineStartedRef.current = true;
318
329
  if (config.mode === "llm") {
319
330
  setChips(config.agent.suggestedChips ?? []);
320
- dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "ready" } });
331
+ dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "loading-model" } });
332
+ try {
333
+ embedderRef.current = new EmbedderRuntime();
334
+ await embedderRef.current.load(config.agent.model);
335
+ dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "ready" } });
336
+ notifyEngineReady();
337
+ } catch (err) {
338
+ console.warn("[sagedesk] WASM embedder failed to load - LLM mode will use fallback messages.", err);
339
+ dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "error-model", error: String(err) } });
340
+ notifyEngineReady();
341
+ }
321
342
  return;
322
343
  }
323
344
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "loading-index" } });
@@ -329,6 +350,7 @@ function useSageDesk(config) {
329
350
  type: "SET_ENGINE_STATUS",
330
351
  payload: { status: "error-index", error: String(err) }
331
352
  });
353
+ notifyEngineReady();
332
354
  addMessage({
333
355
  role: "bot",
334
356
  text: "I'm having trouble loading right now. Please try again in a moment."
@@ -341,12 +363,14 @@ function useSageDesk(config) {
341
363
  embedderRef.current = new EmbedderRuntime();
342
364
  await embedderRef.current.load(config.agent.model);
343
365
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "ready" } });
366
+ notifyEngineReady();
344
367
  } catch (err) {
345
368
  console.warn("[sagedesk] WASM model failed to load, falling back to keyword search -", err);
346
369
  embedderRef.current = new EmbedderRuntime();
347
370
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "degraded" } });
371
+ notifyEngineReady();
348
372
  }
349
- }, [config.mode, config.indexUrl, config.agent.suggestedChips, addMessage]);
373
+ }, [config.mode, config.indexUrl, config.agent.model, config.agent.suggestedChips, addMessage, notifyEngineReady]);
350
374
  const greetingShownRef = useRef(false);
351
375
  const open = useCallback(() => {
352
376
  dispatch({ type: "OPEN" });
@@ -363,16 +387,12 @@ function useSageDesk(config) {
363
387
  dispatch({ type: "CLOSE" });
364
388
  }, []);
365
389
  const waitForEngine = useCallback(() => {
390
+ const s = engineStatusRef.current;
391
+ if (s === "ready" || s === "degraded" || s === "error-index" || s === "error-model") {
392
+ return Promise.resolve();
393
+ }
366
394
  return new Promise((resolve) => {
367
- const check = () => {
368
- const s = engineStatusRef.current;
369
- if (s === "ready" || s === "degraded" || s === "error-index" || s === "error-model") {
370
- resolve();
371
- } else {
372
- setTimeout(check, 100);
373
- }
374
- };
375
- check();
395
+ engineReadyCallbacksRef.current.push(resolve);
376
396
  });
377
397
  }, []);
378
398
  const submit = useCallback(
@@ -396,12 +416,20 @@ function useSageDesk(config) {
396
416
  console.warn('[sagedesk] LLM mode requires an "endpoint" prop.');
397
417
  botText = getFallback(config.agent);
398
418
  isFallback = true;
419
+ } else if (!embedderRef.current?.isReady) {
420
+ console.warn("[sagedesk] Embedder not ready - showing fallback.");
421
+ botText = getFallback(config.agent);
422
+ isFallback = true;
399
423
  } else {
400
424
  try {
425
+ const vector = await embedderRef.current.embed(trimmed);
401
426
  const res = await fetch(config.endpoint, {
402
427
  method: "POST",
403
428
  headers: { "Content-Type": "application/json" },
404
- body: JSON.stringify({ query: trimmed })
429
+ body: JSON.stringify({
430
+ query: trimmed,
431
+ queryVector: Array.from(vector)
432
+ })
405
433
  });
406
434
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
407
435
  const data = await res.json();
@@ -445,8 +473,8 @@ function useSageDesk(config) {
445
473
  }
446
474
  if (config.mode !== "llm") {
447
475
  const elapsed = Date.now() - typingStart;
448
- const delayBase = retrievalMode === "keyword" || isFallback ? 800 : 3e3;
449
- const minTypingMs = delayBase + Math.random() * 2e3;
476
+ const delayBase = retrievalMode === "keyword" || isFallback ? 400 : 800;
477
+ const minTypingMs = delayBase + Math.random() * 400;
450
478
  const remaining = minTypingMs - elapsed;
451
479
  if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
452
480
  }
@@ -464,11 +492,9 @@ function useSageDesk(config) {
464
492
  return () => document.removeEventListener("keydown", handler);
465
493
  }, [state.isOpen, close]);
466
494
  const activeChips = useMemo(() => {
467
- const askedTexts = new Set(
468
- state.messages.filter((m) => m.role === "user").map((m) => m.text.toLowerCase().trim())
469
- );
495
+ const askedTexts = new Set(state.userMessages.map((m) => m.text.toLowerCase().trim()));
470
496
  return chips.filter((chip) => !askedTexts.has(chip.toLowerCase().trim()));
471
- }, [chips, state.messages]);
497
+ }, [chips, state.userMessages]);
472
498
  return { state, chips: activeChips, open, close, submit };
473
499
  }
474
500
 
@@ -664,9 +690,9 @@ var PoweredBy = ({ dark = false }) => /* @__PURE__ */ jsxs("div", { style: {
664
690
  }
665
691
  )
666
692
  ] });
667
- function ClassicMessageBubble({ msg, accent }) {
693
+ var ClassicMessageBubble = React.memo(function ClassicMessageBubble2({ msg, accent }) {
668
694
  const isBot = msg.role === "bot";
669
- const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
695
+ const renderedHtml = useMemo2(() => isBot ? parseMarkdown(msg.text) : msg.text, [isBot, msg.text]);
670
696
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", alignItems: isBot ? "flex-start" : "flex-end", gap: "4px" }, children: [
671
697
  msg.isFallback && /* @__PURE__ */ jsx("p", { style: { fontSize: "11px", fontWeight: 500, color: "#9b9aa3", margin: 0, padding: "0 4px", fontFamily: "inherit" }, children: "Not sure about that one" }),
672
698
  /* @__PURE__ */ jsx("div", { style: {
@@ -691,7 +717,7 @@ function ClassicMessageBubble({ msg, accent }) {
691
717
  fontFamily: "inherit"
692
718
  }, children: "just now" })
693
719
  ] });
694
- }
720
+ });
695
721
  function ClassicTypingIndicator() {
696
722
  const dot = { width: 6, height: 6, borderRadius: "50%", background: "#c8c8ce", display: "inline-block" };
697
723
  return /* @__PURE__ */ jsx("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "4px" }, children: /* @__PURE__ */ jsxs("div", { style: {
@@ -709,9 +735,9 @@ function ClassicTypingIndicator() {
709
735
  /* @__PURE__ */ jsx("span", { style: dot, className: "sd-r-dot-3" })
710
736
  ] }) });
711
737
  }
712
- function LightMessageBubble({ msg, accent, agentName }) {
738
+ var LightMessageBubble = React.memo(function LightMessageBubble2({ msg, accent, agentName }) {
713
739
  const isBot = msg.role === "bot";
714
- const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
740
+ const renderedHtml = useMemo2(() => isBot ? parseMarkdown(msg.text) : msg.text, [isBot, msg.text]);
715
741
  if (isBot) {
716
742
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "10px" }, children: [
717
743
  /* @__PURE__ */ jsx("div", { style: {
@@ -746,7 +772,7 @@ function LightMessageBubble({ msg, accent, agentName }) {
746
772
  wordBreak: "break-word",
747
773
  fontFamily: "inherit"
748
774
  }, children: msg.text }) });
749
- }
775
+ });
750
776
  function LightTypingIndicator({ accent }) {
751
777
  const dot = { width: 6, height: 6, borderRadius: "50%", background: "#c4c4be", display: "inline-block" };
752
778
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "10px" }, children: [
@@ -774,9 +800,9 @@ function LightTypingIndicator({ accent }) {
774
800
  ] })
775
801
  ] });
776
802
  }
777
- function DarkMessageBubble({ msg, accent }) {
803
+ var DarkMessageBubble = React.memo(function DarkMessageBubble2({ msg, accent }) {
778
804
  const isBot = msg.role === "bot";
779
- const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
805
+ const renderedHtml = useMemo2(() => isBot ? parseMarkdown(msg.text) : msg.text, [isBot, msg.text]);
780
806
  return /* @__PURE__ */ jsxs("div", { style: {
781
807
  maxWidth: "85%",
782
808
  padding: "12px 14px",
@@ -794,7 +820,7 @@ function DarkMessageBubble({ msg, accent }) {
794
820
  msg.isFallback && /* @__PURE__ */ jsx("span", { style: { fontSize: "11px", color: "rgba(255,255,255,0.5)", display: "block", marginBottom: "4px" }, children: "Not sure about that one" }),
795
821
  isBot ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) : /* @__PURE__ */ jsx("div", { style: { whiteSpace: "pre-wrap" }, children: msg.text })
796
822
  ] });
797
- }
823
+ });
798
824
  function DarkTypingIndicator() {
799
825
  const dot = { width: 6, height: 6, borderRadius: "50%", background: "rgba(255,255,255,0.4)", display: "inline-block" };
800
826
  return /* @__PURE__ */ jsxs("div", { style: {
@@ -813,7 +839,7 @@ function DarkTypingIndicator() {
813
839
  /* @__PURE__ */ jsx("span", { style: dot, className: "sd-r-dot-3" })
814
840
  ] });
815
841
  }
816
- function renderClassic(p) {
842
+ function ClassicTheme(p) {
817
843
  const {
818
844
  agent,
819
845
  state,
@@ -1002,7 +1028,7 @@ function renderClassic(p) {
1002
1028
  ] })
1003
1029
  ] });
1004
1030
  }
1005
- function renderLight(p) {
1031
+ function LightTheme(p) {
1006
1032
  const {
1007
1033
  agent,
1008
1034
  state,
@@ -1190,7 +1216,7 @@ function renderLight(p) {
1190
1216
  ] })
1191
1217
  ] });
1192
1218
  }
1193
- function renderDark(p) {
1219
+ function DarkTheme(p) {
1194
1220
  const {
1195
1221
  agent,
1196
1222
  state,
@@ -1427,7 +1453,10 @@ function SageDeskWidget({ mode, indexUrl, endpoint, agent, search: search2 }) {
1427
1453
  '[sagedesk] Required prop "endpoint" is missing for llm mode. Provide your backend route, e.g. endpoint="/api/sagedesk".'
1428
1454
  );
1429
1455
  }
1430
- const config = { mode: resolvedMode, indexUrl, endpoint, agent, search: search2 };
1456
+ const config = useMemo2(
1457
+ () => ({ mode: resolvedMode, indexUrl, endpoint, agent, search: search2 }),
1458
+ [resolvedMode, indexUrl, endpoint, agent, search2]
1459
+ );
1431
1460
  const { state, chips, open, close, submit } = useSageDesk(config);
1432
1461
  const theme = agent.theme ?? "classic";
1433
1462
  const accent = agent.accentColor ?? "#534AB7";
@@ -1506,10 +1535,10 @@ function SageDeskWidget({ mode, indexUrl, endpoint, agent, search: search2 }) {
1506
1535
  open,
1507
1536
  submit
1508
1537
  };
1509
- const content = theme === "dark" ? renderDark(props) : theme === "light" ? renderLight(props) : renderClassic(props);
1538
+ const content = theme === "dark" ? /* @__PURE__ */ jsx(DarkTheme, { ...props }) : theme === "light" ? /* @__PURE__ */ jsx(LightTheme, { ...props }) : /* @__PURE__ */ jsx(ClassicTheme, { ...props });
1510
1539
  return createPortal(content, document.body);
1511
1540
  }
1512
1541
  export {
1513
1542
  SageDeskWidget
1514
1543
  };
1515
- //# sourceMappingURL=SageDeskWidget-SJVE6QK3.js.map
1544
+ //# sourceMappingURL=SageDeskWidget-ZJJGXTTC.js.map