sagedesk 1.0.0 → 2.1.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.
@@ -121,17 +121,33 @@ function dotProduct(a, b) {
121
121
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
122
122
  return dot;
123
123
  }
124
+ function insertSorted(arr, item, maxLen) {
125
+ arr.push(item);
126
+ let i = arr.length - 1;
127
+ while (i > 0 && arr[i - 1].score < arr[i].score) {
128
+ const tmp = arr[i - 1];
129
+ arr[i - 1] = arr[i];
130
+ arr[i] = tmp;
131
+ i--;
132
+ }
133
+ if (arr.length > maxLen) arr.pop();
134
+ }
124
135
  function search(queryVector, index, topK = 3, minScore = 0.42) {
125
136
  const results = [];
126
137
  for (const chunk of index) {
127
138
  const score = dotProduct(queryVector, chunk.vector384);
128
139
  if (score < minScore) continue;
129
140
  if (results.length < topK) {
130
- results.push({ chunk, score });
131
- results.sort((a, b) => b.score - a.score);
141
+ insertSorted(results, { chunk, score }, topK);
132
142
  } else if (score > results[topK - 1].score) {
133
143
  results[topK - 1] = { chunk, score };
134
- results.sort((a, b) => b.score - a.score);
144
+ let i = topK - 1;
145
+ while (i > 0 && results[i - 1].score < results[i].score) {
146
+ const tmp = results[i - 1];
147
+ results[i - 1] = results[i];
148
+ results[i] = tmp;
149
+ i--;
150
+ }
135
151
  }
136
152
  }
137
153
  return results;
@@ -142,15 +158,23 @@ function keywordSearch(query, index, topK = 3) {
142
158
  const results = [];
143
159
  for (const chunk of index) {
144
160
  const chunkLower = chunk.textLower || chunk.text.toLowerCase();
145
- const matchCount = terms.filter((t) => chunkLower.includes(t)).length;
161
+ let matchCount = 0;
162
+ for (const t of terms) {
163
+ if (chunkLower.includes(t)) matchCount++;
164
+ }
146
165
  const score = matchCount / terms.length;
147
166
  if (score <= 0) continue;
148
167
  if (results.length < topK) {
149
- results.push({ chunk, score });
150
- results.sort((a, b) => b.score - a.score);
168
+ insertSorted(results, { chunk, score }, topK);
151
169
  } else if (score > results[topK - 1].score) {
152
170
  results[topK - 1] = { chunk, score };
153
- results.sort((a, b) => b.score - a.score);
171
+ let i = topK - 1;
172
+ while (i > 0 && results[i - 1].score < results[i].score) {
173
+ const tmp = results[i - 1];
174
+ results[i - 1] = results[i];
175
+ results[i] = tmp;
176
+ i--;
177
+ }
154
178
  }
155
179
  }
156
180
  return results;
@@ -275,6 +299,17 @@ var init_fallback = __esm({
275
299
  });
276
300
 
277
301
  // src/react/useSageDesk.ts
302
+ function logFallbackWarning(reason) {
303
+ if (!reason) return;
304
+ const messages = {
305
+ "auth-error": "[sagedesk] Support service authentication failed. Showing relevant knowledge instead.",
306
+ "quota-exceeded": "[sagedesk] Support service quota exhausted. Showing relevant knowledge instead.",
307
+ "timeout": "[sagedesk] Support service took too long to respond. Showing relevant knowledge instead.",
308
+ "api-error": "[sagedesk] Support service error. Showing relevant knowledge instead.",
309
+ "malformed-response": "[sagedesk] Support service returned invalid response. Showing relevant knowledge instead."
310
+ };
311
+ console.warn(messages[reason] || "[sagedesk] Support service unavailable. Showing relevant knowledge instead.");
312
+ }
278
313
  function reducer(state, action) {
279
314
  switch (action.type) {
280
315
  case "OPEN":
@@ -319,6 +354,11 @@ function useSageDesk(config) {
319
354
  const startEngine = (0, import_react.useCallback)(async () => {
320
355
  if (engineStartedRef.current) return;
321
356
  engineStartedRef.current = true;
357
+ if (config.mode === "llm") {
358
+ setChips(config.agent.suggestedChips ?? []);
359
+ dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "ready" } });
360
+ return;
361
+ }
322
362
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "loading-index" } });
323
363
  try {
324
364
  indexRef.current = await fetchIndex(config.indexUrl);
@@ -345,7 +385,7 @@ function useSageDesk(config) {
345
385
  embedderRef.current = new EmbedderRuntime();
346
386
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "degraded" } });
347
387
  }
348
- }, [config.indexUrl, config.agent.suggestedChips, addMessage]);
388
+ }, [config.mode, config.indexUrl, config.agent.suggestedChips, addMessage]);
349
389
  const greetingShownRef = (0, import_react.useRef)(false);
350
390
  const open = (0, import_react.useCallback)(() => {
351
391
  dispatch({ type: "OPEN" });
@@ -388,8 +428,37 @@ function useSageDesk(config) {
388
428
  }
389
429
  let botText;
390
430
  let isFallback = false;
391
- let mode = "keyword";
392
- if (!indexRef.current) {
431
+ let fallbackReason;
432
+ let retrievalMode = "keyword";
433
+ if (config.mode === "llm") {
434
+ if (!config.endpoint) {
435
+ console.warn('[sagedesk] LLM mode requires an "endpoint" prop.');
436
+ botText = getFallback(config.agent);
437
+ isFallback = true;
438
+ } else {
439
+ try {
440
+ const res = await fetch(config.endpoint, {
441
+ method: "POST",
442
+ headers: { "Content-Type": "application/json" },
443
+ body: JSON.stringify({ query: trimmed })
444
+ });
445
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
446
+ const data = await res.json();
447
+ if (data.isFallback || !data.answer) {
448
+ fallbackReason = data.fallbackReason;
449
+ logFallbackWarning(fallbackReason);
450
+ botText = getFallback(config.agent);
451
+ isFallback = true;
452
+ } else {
453
+ botText = data.answer;
454
+ }
455
+ } catch (err) {
456
+ console.warn("[sagedesk] Support service unavailable. Using cached knowledge instead.");
457
+ botText = getFallback(config.agent);
458
+ isFallback = true;
459
+ }
460
+ }
461
+ } else if (!indexRef.current) {
393
462
  botText = getFallback(config.agent);
394
463
  isFallback = true;
395
464
  } else {
@@ -400,7 +469,7 @@ function useSageDesk(config) {
400
469
  embedderRef.current,
401
470
  config.search
402
471
  );
403
- mode = res.mode;
472
+ retrievalMode = res.mode;
404
473
  if (res.results.length > 0) {
405
474
  botText = buildAnswer(res.results);
406
475
  } else {
@@ -413,11 +482,13 @@ function useSageDesk(config) {
413
482
  isFallback = true;
414
483
  }
415
484
  }
416
- const elapsed = Date.now() - typingStart;
417
- const delayBase = mode === "keyword" || isFallback ? 800 : 3e3;
418
- const minTypingMs = delayBase + Math.random() * 2e3;
419
- const remaining = minTypingMs - elapsed;
420
- if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
485
+ if (config.mode !== "llm") {
486
+ const elapsed = Date.now() - typingStart;
487
+ const delayBase = retrievalMode === "keyword" || isFallback ? 800 : 3e3;
488
+ const minTypingMs = delayBase + Math.random() * 2e3;
489
+ const remaining = minTypingMs - elapsed;
490
+ if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
491
+ }
421
492
  dispatch({ type: "SET_TYPING", payload: false });
422
493
  addMessage({ role: "bot", text: botText, isFallback });
423
494
  },
@@ -446,7 +517,6 @@ var init_useSageDesk = __esm({
446
517
  import_react = require("react");
447
518
  init_embedder();
448
519
  init_retriever();
449
- init_retriever();
450
520
  init_renderer();
451
521
  init_fallback();
452
522
  initialState = {
@@ -460,6 +530,57 @@ var init_useSageDesk = __esm({
460
530
  }
461
531
  });
462
532
 
533
+ // src/react/markdownUtils.ts
534
+ function parseMarkdown(markdown) {
535
+ const html = import_marked.marked.parse(markdown);
536
+ const sanitized = import_dompurify.default.sanitize(html, PURIFY_CONFIG);
537
+ return sanitized;
538
+ }
539
+ var import_marked, import_dompurify, PURIFY_CONFIG;
540
+ var init_markdownUtils = __esm({
541
+ "src/react/markdownUtils.ts"() {
542
+ "use strict";
543
+ import_marked = require("marked");
544
+ import_dompurify = __toESM(require("dompurify"), 1);
545
+ import_marked.marked.setOptions({
546
+ breaks: true,
547
+ gfm: true,
548
+ pedantic: false
549
+ });
550
+ PURIFY_CONFIG = {
551
+ ALLOWED_TAGS: [
552
+ "p",
553
+ "br",
554
+ "strong",
555
+ "em",
556
+ "u",
557
+ "h1",
558
+ "h2",
559
+ "h3",
560
+ "h4",
561
+ "h5",
562
+ "h6",
563
+ "ul",
564
+ "ol",
565
+ "li",
566
+ "blockquote",
567
+ "code",
568
+ "pre",
569
+ "a",
570
+ "hr"
571
+ ],
572
+ ALLOWED_ATTR: ["href", "title", "target", "rel"],
573
+ ALLOW_DATA_ATTR: false
574
+ };
575
+ import_dompurify.default.addHook("afterSanitizeAttributes", (node) => {
576
+ if (node.tagName.toLowerCase() === "a") {
577
+ node.setAttribute("target", "_blank");
578
+ node.setAttribute("rel", "noopener noreferrer");
579
+ }
580
+ });
581
+ }
582
+ });
583
+
463
584
  // src/react/SageDeskWidget.tsx
464
585
  var SageDeskWidget_exports = {};
465
586
  __export(SageDeskWidget_exports, {
@@ -476,6 +597,7 @@ function injectStyles(theme) {
476
597
  }
477
598
  function ClassicMessageBubble({ msg, accent }) {
478
599
  const isBot = msg.role === "bot";
600
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
479
601
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", flexDirection: "column", alignItems: isBot ? "flex-start" : "flex-end", gap: "4px" }, children: [
480
602
  msg.isFallback && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: "11px", fontWeight: 500, color: "#9b9aa3", margin: 0, padding: "0 4px", fontFamily: "inherit" }, children: "Not sure about that one" }),
481
603
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: {
@@ -488,10 +610,9 @@ function ClassicMessageBubble({ msg, accent }) {
488
610
  color: isBot ? "#1a1a2e" : "#fff",
489
611
  border: isBot ? "1px solid rgba(20,20,40,0.06)" : "none",
490
612
  boxShadow: isBot ? "0 1px 2px rgba(20,20,40,0.04)" : `0 6px 16px -6px color-mix(in oklab, ${accent} 60%, transparent)`,
491
- whiteSpace: "pre-wrap",
492
613
  wordBreak: "break-word",
493
614
  fontFamily: "inherit"
494
- }, children: msg.text }),
615
+ }, className: "sd-r-markdown", children: isBot ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { whiteSpace: "pre-wrap" }, children: msg.text }) }),
495
616
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: {
496
617
  fontSize: "11px",
497
618
  color: "#a8a8b0",
@@ -521,6 +642,7 @@ function ClassicTypingIndicator() {
521
642
  }
522
643
  function LightMessageBubble({ msg, accent, agentName }) {
523
644
  const isBot = msg.role === "bot";
645
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
524
646
  if (isBot) {
525
647
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: "10px" }, children: [
526
648
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: {
@@ -537,7 +659,7 @@ function LightMessageBubble({ msg, accent, agentName }) {
537
659
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: "11px", color: "#a8a89e", fontVariantNumeric: "tabular-nums", fontFamily: "inherit" }, children: "just now" })
538
660
  ] }),
539
661
  msg.isFallback && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: "11px", color: "#9b9aa3", margin: "0 0 4px", fontFamily: "inherit" }, children: "Not sure about that one" }),
540
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: "14px", lineHeight: 1.55, color: "#2a2a36", fontFamily: "inherit", whiteSpace: "pre-wrap", wordBreak: "break-word" }, children: msg.text })
662
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: "14px", lineHeight: 1.55, color: "#2a2a36", fontFamily: "inherit", wordBreak: "break-word" }, className: "sd-r-markdown", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) })
541
663
  ] })
542
664
  ] });
543
665
  }
@@ -585,6 +707,7 @@ function LightTypingIndicator({ accent }) {
585
707
  }
586
708
  function DarkMessageBubble({ msg, accent }) {
587
709
  const isBot = msg.role === "bot";
710
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
588
711
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: {
589
712
  maxWidth: "85%",
590
713
  padding: "12px 14px",
@@ -596,12 +719,11 @@ function DarkMessageBubble({ msg, accent }) {
596
719
  color: isBot ? "rgba(255,255,255,0.92)" : "#fff",
597
720
  alignSelf: isBot ? "flex-start" : "flex-end",
598
721
  boxShadow: isBot ? "none" : `0 8px 20px -8px color-mix(in oklab, ${accent} 70%, transparent)`,
599
- whiteSpace: "pre-wrap",
600
722
  wordBreak: "break-word",
601
723
  fontFamily: "inherit"
602
- }, children: [
724
+ }, className: isBot ? "sd-r-markdown" : "", children: [
603
725
  msg.isFallback && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: "11px", color: "rgba(255,255,255,0.5)", display: "block", marginBottom: "4px" }, children: "Not sure about that one" }),
604
- msg.text
726
+ isBot ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { whiteSpace: "pre-wrap" }, children: msg.text })
605
727
  ] });
606
728
  }
607
729
  function DarkTypingIndicator() {
@@ -1221,16 +1343,22 @@ function renderDark(p) {
1221
1343
  ] })
1222
1344
  ] });
1223
1345
  }
1224
- function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1225
- if (!indexUrl) {
1346
+ function SageDeskWidget({ mode, indexUrl, endpoint, agent, search: search2 }) {
1347
+ const resolvedMode = mode ?? "local";
1348
+ if (!agent?.name) {
1349
+ throw new Error('[sagedesk] Required prop "agent.name" is missing.');
1350
+ }
1351
+ if (resolvedMode === "local" && !indexUrl) {
1226
1352
  throw new Error(
1227
- '[sagedesk] Required prop "indexUrl" is missing. Run `npx sagedesk build` and pass the output path, e.g. indexUrl="/support-index.json".'
1353
+ '[sagedesk] Required prop "indexUrl" is missing for local mode. Run `npx sagedesk build` and pass the output path, e.g. indexUrl="/support-index.json".'
1228
1354
  );
1229
1355
  }
1230
- if (!agent?.name) {
1231
- throw new Error('[sagedesk] Required prop "agent.name" is missing.');
1356
+ if (resolvedMode === "llm" && !endpoint) {
1357
+ throw new Error(
1358
+ '[sagedesk] Required prop "endpoint" is missing for llm mode. Provide your backend route, e.g. endpoint="/api/sagedesk".'
1359
+ );
1232
1360
  }
1233
- const config = { indexUrl, agent, search: search2 };
1361
+ const config = { mode: resolvedMode, indexUrl, endpoint, agent, search: search2 };
1234
1362
  const { state, chips, open, close, submit } = useSageDesk(config);
1235
1363
  const theme = agent.theme ?? "classic";
1236
1364
  const accent = agent.accentColor ?? "#534AB7";
@@ -1243,7 +1371,7 @@ function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1243
1371
  const triggerRef = (0, import_react2.useRef)(null);
1244
1372
  const [mounted, setMounted] = (0, import_react2.useState)(false);
1245
1373
  (0, import_react2.useEffect)(() => {
1246
- if (!indexUrl.startsWith("/") && !indexUrl.startsWith("http")) {
1374
+ if (resolvedMode === "local" && indexUrl && !indexUrl.startsWith("/") && !indexUrl.startsWith("http")) {
1247
1375
  console.warn(
1248
1376
  `[sagedesk] indexUrl "${indexUrl}" looks like a relative path. It should start with "/" so it resolves correctly from any page.`
1249
1377
  );
@@ -1285,7 +1413,7 @@ function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1285
1413
  [handleSubmit]
1286
1414
  );
1287
1415
  if (!mounted || typeof document === "undefined") return null;
1288
- const showPoweredBy = agent.poweredBy !== false;
1416
+ const showPoweredBy = true;
1289
1417
  const showChips = chips.length > 0;
1290
1418
  const panelClass = isClosing ? "sd-r-closing" : state.isOpen ? "sd-r-opening" : "";
1291
1419
  const props = {
@@ -1319,6 +1447,7 @@ var init_SageDeskWidget = __esm({
1319
1447
  import_react2 = require("react");
1320
1448
  import_react_dom = require("react-dom");
1321
1449
  init_useSageDesk();
1450
+ init_markdownUtils();
1322
1451
  import_jsx_runtime = require("react/jsx-runtime");
1323
1452
  STYLE_ID = "sagedesk-widget-styles";
1324
1453
  SHARED = `
@@ -1347,6 +1476,28 @@ var init_SageDeskWidget = __esm({
1347
1476
  }
1348
1477
  .sd-r-scrollable::-webkit-scrollbar { display: none !important; }
1349
1478
  .sd-r-scrollable > * { flex-shrink: 0 !important; }
1479
+ .sd-r-markdown h1, .sd-r-markdown h2, .sd-r-markdown h3, .sd-r-markdown h4, .sd-r-markdown h5, .sd-r-markdown h6 {
1480
+ margin: 12px 0 8px 0 !important; font-weight: 600 !important; line-height: 1.3 !important;
1481
+ }
1482
+ .sd-r-markdown h1 { font-size: 1.3em !important; }
1483
+ .sd-r-markdown h2 { font-size: 1.2em !important; }
1484
+ .sd-r-markdown h3 { font-size: 1.1em !important; }
1485
+ .sd-r-markdown h4, .sd-r-markdown h5, .sd-r-markdown h6 { font-size: 1em !important; }
1486
+ .sd-r-markdown strong { font-weight: 600 !important; }
1487
+ .sd-r-markdown em { font-style: italic !important; }
1488
+ .sd-r-markdown u { text-decoration: underline !important; }
1489
+ .sd-r-markdown ul, .sd-r-markdown ol { margin: 8px 0 !important; padding-left: 20px !important; }
1490
+ .sd-r-markdown li { margin: 4px 0 !important; }
1491
+ .sd-r-markdown blockquote { margin: 8px 0 !important; padding-left: 12px !important; border-left: 3px solid currentColor !important; opacity: 0.8 !important; }
1492
+ .sd-r-markdown code { font-family: 'Monaco', 'Courier New', monospace !important; font-size: 0.9em !important; padding: 2px 4px !important; background: rgba(0,0,0,0.05) !important; border-radius: 3px !important; }
1493
+ .sd-r-markdown pre { background: rgba(0,0,0,0.05) !important; padding: 8px 10px !important; border-radius: 6px !important; overflow-x: auto !important; margin: 8px 0 !important; }
1494
+ .sd-r-markdown pre code { background: none !important; padding: 0 !important; }
1495
+ .sd-r-markdown hr { border: none !important; border-top: 1px solid currentColor !important; opacity: 0.3 !important; margin: 10px 0 !important; }
1496
+ .sd-r-markdown a { text-decoration: underline !important; opacity: 0.9 !important; }
1497
+ .sd-r-markdown a:hover { opacity: 1 !important; }
1498
+ .sd-r-markdown p { margin: 6px 0 !important; }
1499
+ .sd-r-markdown > *:first-child { margin-top: 0 !important; }
1500
+ .sd-r-markdown > *:last-child { margin-bottom: 0 !important; }
1350
1501
  @media (max-width: 420px) {
1351
1502
  .sd-r-panel {
1352
1503
  bottom: 0 !important; right: 0 !important; left: 0 !important;
@@ -1424,7 +1575,7 @@ var init_SageDeskWidget = __esm({
1424
1575
  {
1425
1576
  href: "https://github.com/mzeeshanwahid/sagedesk",
1426
1577
  target: "_blank",
1427
- rel: "noopener noreferrer",
1578
+ rel: "noopener",
1428
1579
  style: {
1429
1580
  color: dark ? "rgba(255,255,255,0.7)" : "#5a5a64",
1430
1581
  fontWeight: 500,
@@ -1457,17 +1608,23 @@ var LazyWidget = (0, import_react3.lazy)(
1457
1608
  function SageDeskNext(props) {
1458
1609
  const [mounted, setMounted] = (0, import_react3.useState)(false);
1459
1610
  (0, import_react3.useEffect)(() => {
1460
- if (!props.indexUrl) {
1611
+ const mode = props.mode ?? "local";
1612
+ if (mode === "local" && !props.indexUrl) {
1461
1613
  console.warn(
1462
1614
  '[sagedesk] Missing required prop: indexUrl. The widget will not load.\nMake sure you ran `npx sagedesk build` and are passing indexUrl="/support-index.json" (or wherever you placed the output file in public/).'
1463
1615
  );
1464
- } else if (!props.indexUrl.startsWith("/") && !props.indexUrl.startsWith("http")) {
1616
+ } else if (mode === "llm" && !props.endpoint) {
1617
+ console.warn(
1618
+ '[sagedesk] Missing required prop: endpoint for LLM mode. The widget will not load.\nProvide your backend route, e.g. endpoint="/api/sagedesk".'
1619
+ );
1620
+ }
1621
+ if (props.indexUrl && !props.indexUrl.startsWith("/") && !props.indexUrl.startsWith("http")) {
1465
1622
  console.warn(
1466
1623
  `[sagedesk] indexUrl "${props.indexUrl}" looks like a relative path. It should start with "/" (e.g. "/support-index.json") so it resolves correctly from any page.`
1467
1624
  );
1468
1625
  }
1469
1626
  setMounted(true);
1470
- }, []);
1627
+ }, [props.mode, props.indexUrl, props.endpoint]);
1471
1628
  if (!mounted) return null;
1472
1629
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LazyWidget, { ...props }) });
1473
1630
  }