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.
@@ -2,6 +2,7 @@ import React from 'react';
2
2
 
3
3
  type SageDeskModel = 'all-MiniLM-L6-v2' | 'bge-small-en-v1-5' | 'paraphrase-multilingual-MiniLM-L12-v2' | 'all-mpnet-base-v2';
4
4
  type Theme = 'classic' | 'light' | 'dark';
5
+ type SageDeskMode = 'local' | 'llm';
5
6
  interface AgentConfig {
6
7
  name: string;
7
8
  model?: SageDeskModel;
@@ -13,7 +14,6 @@ interface AgentConfig {
13
14
  position?: 'bottom-right' | 'bottom-left';
14
15
  avatarUrl?: string;
15
16
  contactUrl?: string;
16
- poweredBy?: boolean;
17
17
  suggestedChips?: string[];
18
18
  }
19
19
  interface SearchConfig {
@@ -21,25 +21,34 @@ interface SearchConfig {
21
21
  topK?: number;
22
22
  }
23
23
  interface SageDeskConfig {
24
- indexUrl: string;
24
+ mode?: SageDeskMode;
25
+ indexUrl?: string;
26
+ endpoint?: string;
25
27
  agent: AgentConfig;
26
28
  search?: SearchConfig;
27
29
  }
30
+ type FallbackReason = 'auth-error' | 'quota-exceeded' | 'timeout' | 'api-error' | 'malformed-response';
28
31
  interface ChatMessage {
29
32
  id: string;
30
33
  role: 'user' | 'bot';
31
34
  text: string;
32
35
  isFallback?: boolean;
36
+ fallbackReason?: FallbackReason;
33
37
  timestamp: Date;
34
38
  }
35
39
  type EngineStatus = 'idle' | 'loading-index' | 'loading-model' | 'ready' | 'error-index' | 'error-model' | 'degraded';
36
40
 
37
41
  interface SageDeskWidgetProps {
38
- indexUrl: string;
42
+ /** Operating mode. 'local' (default) runs entirely in the browser via WASM. 'llm' posts to the consumer's own backend. */
43
+ mode?: SageDeskMode;
44
+ /** URL to the pre-built vector index. Required in local mode. */
45
+ indexUrl?: string;
46
+ /** Consumer's backend endpoint that accepts POST { query }. Required in llm mode. */
47
+ endpoint?: string;
39
48
  agent: SageDeskConfig['agent'];
40
49
  search?: SageDeskConfig['search'];
41
50
  }
42
- declare function SageDeskWidget({ indexUrl, agent, search }: SageDeskWidgetProps): React.ReactPortal | null;
51
+ declare function SageDeskWidget({ mode, indexUrl, endpoint, agent, search }: SageDeskWidgetProps): React.ReactPortal | null;
43
52
 
44
53
  interface State {
45
54
  messages: ChatMessage[];
@@ -58,4 +67,4 @@ interface UseSageDeskReturn {
58
67
  }
59
68
  declare function useSageDesk(config: SageDeskConfig): UseSageDeskReturn;
60
69
 
61
- export { type AgentConfig, type ChatMessage, type SageDeskConfig, SageDeskWidget, type SageDeskWidgetProps, type SearchConfig, type UseSageDeskReturn, useSageDesk };
70
+ export { type AgentConfig, type ChatMessage, type SageDeskConfig, type SageDeskMode, SageDeskWidget, type SageDeskWidgetProps, type SearchConfig, type UseSageDeskReturn, useSageDesk };
@@ -94,17 +94,33 @@ function dotProduct(a, b) {
94
94
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
95
95
  return dot;
96
96
  }
97
+ function insertSorted(arr, item, maxLen) {
98
+ arr.push(item);
99
+ let i = arr.length - 1;
100
+ while (i > 0 && arr[i - 1].score < arr[i].score) {
101
+ const tmp = arr[i - 1];
102
+ arr[i - 1] = arr[i];
103
+ arr[i] = tmp;
104
+ i--;
105
+ }
106
+ if (arr.length > maxLen) arr.pop();
107
+ }
97
108
  function search(queryVector, index, topK = 3, minScore = 0.42) {
98
109
  const results = [];
99
110
  for (const chunk of index) {
100
111
  const score = dotProduct(queryVector, chunk.vector384);
101
112
  if (score < minScore) continue;
102
113
  if (results.length < topK) {
103
- results.push({ chunk, score });
104
- results.sort((a, b) => b.score - a.score);
114
+ insertSorted(results, { chunk, score }, topK);
105
115
  } else if (score > results[topK - 1].score) {
106
116
  results[topK - 1] = { chunk, score };
107
- results.sort((a, b) => b.score - a.score);
117
+ let i = topK - 1;
118
+ while (i > 0 && results[i - 1].score < results[i].score) {
119
+ const tmp = results[i - 1];
120
+ results[i - 1] = results[i];
121
+ results[i] = tmp;
122
+ i--;
123
+ }
108
124
  }
109
125
  }
110
126
  return results;
@@ -115,15 +131,23 @@ function keywordSearch(query, index, topK = 3) {
115
131
  const results = [];
116
132
  for (const chunk of index) {
117
133
  const chunkLower = chunk.textLower || chunk.text.toLowerCase();
118
- const matchCount = terms.filter((t) => chunkLower.includes(t)).length;
134
+ let matchCount = 0;
135
+ for (const t of terms) {
136
+ if (chunkLower.includes(t)) matchCount++;
137
+ }
119
138
  const score = matchCount / terms.length;
120
139
  if (score <= 0) continue;
121
140
  if (results.length < topK) {
122
- results.push({ chunk, score });
123
- results.sort((a, b) => b.score - a.score);
141
+ insertSorted(results, { chunk, score }, topK);
124
142
  } else if (score > results[topK - 1].score) {
125
143
  results[topK - 1] = { chunk, score };
126
- 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
+ }
127
151
  }
128
152
  }
129
153
  return results;
@@ -226,6 +250,17 @@ function getFallback(config) {
226
250
  }
227
251
 
228
252
  // src/react/useSageDesk.ts
253
+ function logFallbackWarning(reason) {
254
+ if (!reason) return;
255
+ const messages = {
256
+ "auth-error": "[sagedesk] Support service authentication failed. Showing relevant knowledge instead.",
257
+ "quota-exceeded": "[sagedesk] Support service quota exhausted. Showing relevant knowledge instead.",
258
+ "timeout": "[sagedesk] Support service took too long to respond. Showing relevant knowledge instead.",
259
+ "api-error": "[sagedesk] Support service error. Showing relevant knowledge instead.",
260
+ "malformed-response": "[sagedesk] Support service returned invalid response. Showing relevant knowledge instead."
261
+ };
262
+ console.warn(messages[reason] || "[sagedesk] Support service unavailable. Showing relevant knowledge instead.");
263
+ }
229
264
  var initialState = {
230
265
  messages: [],
231
266
  isOpen: false,
@@ -278,6 +313,11 @@ function useSageDesk(config) {
278
313
  const startEngine = useCallback(async () => {
279
314
  if (engineStartedRef.current) return;
280
315
  engineStartedRef.current = true;
316
+ if (config.mode === "llm") {
317
+ setChips(config.agent.suggestedChips ?? []);
318
+ dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "ready" } });
319
+ return;
320
+ }
281
321
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "loading-index" } });
282
322
  try {
283
323
  indexRef.current = await fetchIndex(config.indexUrl);
@@ -304,7 +344,7 @@ function useSageDesk(config) {
304
344
  embedderRef.current = new EmbedderRuntime();
305
345
  dispatch({ type: "SET_ENGINE_STATUS", payload: { status: "degraded" } });
306
346
  }
307
- }, [config.indexUrl, config.agent.suggestedChips, addMessage]);
347
+ }, [config.mode, config.indexUrl, config.agent.suggestedChips, addMessage]);
308
348
  const greetingShownRef = useRef(false);
309
349
  const open = useCallback(() => {
310
350
  dispatch({ type: "OPEN" });
@@ -347,8 +387,37 @@ function useSageDesk(config) {
347
387
  }
348
388
  let botText;
349
389
  let isFallback = false;
350
- let mode = "keyword";
351
- if (!indexRef.current) {
390
+ let fallbackReason;
391
+ let retrievalMode = "keyword";
392
+ if (config.mode === "llm") {
393
+ if (!config.endpoint) {
394
+ console.warn('[sagedesk] LLM mode requires an "endpoint" prop.');
395
+ botText = getFallback(config.agent);
396
+ isFallback = true;
397
+ } else {
398
+ try {
399
+ const res = await fetch(config.endpoint, {
400
+ method: "POST",
401
+ headers: { "Content-Type": "application/json" },
402
+ body: JSON.stringify({ query: trimmed })
403
+ });
404
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
405
+ const data = await res.json();
406
+ if (data.isFallback || !data.answer) {
407
+ fallbackReason = data.fallbackReason;
408
+ logFallbackWarning(fallbackReason);
409
+ botText = getFallback(config.agent);
410
+ isFallback = true;
411
+ } else {
412
+ botText = data.answer;
413
+ }
414
+ } catch (err) {
415
+ console.warn("[sagedesk] Support service unavailable. Using cached knowledge instead.");
416
+ botText = getFallback(config.agent);
417
+ isFallback = true;
418
+ }
419
+ }
420
+ } else if (!indexRef.current) {
352
421
  botText = getFallback(config.agent);
353
422
  isFallback = true;
354
423
  } else {
@@ -359,7 +428,7 @@ function useSageDesk(config) {
359
428
  embedderRef.current,
360
429
  config.search
361
430
  );
362
- mode = res.mode;
431
+ retrievalMode = res.mode;
363
432
  if (res.results.length > 0) {
364
433
  botText = buildAnswer(res.results);
365
434
  } else {
@@ -372,11 +441,13 @@ function useSageDesk(config) {
372
441
  isFallback = true;
373
442
  }
374
443
  }
375
- const elapsed = Date.now() - typingStart;
376
- const delayBase = mode === "keyword" || isFallback ? 800 : 3e3;
377
- const minTypingMs = delayBase + Math.random() * 2e3;
378
- const remaining = minTypingMs - elapsed;
379
- if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
444
+ if (config.mode !== "llm") {
445
+ const elapsed = Date.now() - typingStart;
446
+ const delayBase = retrievalMode === "keyword" || isFallback ? 800 : 3e3;
447
+ const minTypingMs = delayBase + Math.random() * 2e3;
448
+ const remaining = minTypingMs - elapsed;
449
+ if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
450
+ }
380
451
  dispatch({ type: "SET_TYPING", payload: false });
381
452
  addMessage({ role: "bot", text: botText, isFallback });
382
453
  },
@@ -399,6 +470,51 @@ function useSageDesk(config) {
399
470
  return { state, chips: activeChips, open, close, submit };
400
471
  }
401
472
 
473
+ // src/react/markdownUtils.ts
474
+ import { marked } from "marked";
475
+ import DOMPurify from "dompurify";
476
+ marked.setOptions({
477
+ breaks: true,
478
+ gfm: true,
479
+ pedantic: false
480
+ });
481
+ var PURIFY_CONFIG = {
482
+ ALLOWED_TAGS: [
483
+ "p",
484
+ "br",
485
+ "strong",
486
+ "em",
487
+ "u",
488
+ "h1",
489
+ "h2",
490
+ "h3",
491
+ "h4",
492
+ "h5",
493
+ "h6",
494
+ "ul",
495
+ "ol",
496
+ "li",
497
+ "blockquote",
498
+ "code",
499
+ "pre",
500
+ "a",
501
+ "hr"
502
+ ],
503
+ ALLOWED_ATTR: ["href", "title", "target", "rel"],
504
+ ALLOW_DATA_ATTR: false
505
+ };
506
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
507
+ if (node.tagName.toLowerCase() === "a") {
508
+ node.setAttribute("target", "_blank");
509
+ node.setAttribute("rel", "noopener noreferrer");
510
+ }
511
+ });
512
+ function parseMarkdown(markdown) {
513
+ const html = marked.parse(markdown);
514
+ const sanitized = DOMPurify.sanitize(html, PURIFY_CONFIG);
515
+ return sanitized;
516
+ }
517
+
402
518
  // src/react/SageDeskWidget.tsx
403
519
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
404
520
  var STYLE_ID = "sagedesk-widget-styles";
@@ -428,6 +544,28 @@ var SHARED = `
428
544
  }
429
545
  .sd-r-scrollable::-webkit-scrollbar { display: none !important; }
430
546
  .sd-r-scrollable > * { flex-shrink: 0 !important; }
547
+ .sd-r-markdown h1, .sd-r-markdown h2, .sd-r-markdown h3, .sd-r-markdown h4, .sd-r-markdown h5, .sd-r-markdown h6 {
548
+ margin: 12px 0 8px 0 !important; font-weight: 600 !important; line-height: 1.3 !important;
549
+ }
550
+ .sd-r-markdown h1 { font-size: 1.3em !important; }
551
+ .sd-r-markdown h2 { font-size: 1.2em !important; }
552
+ .sd-r-markdown h3 { font-size: 1.1em !important; }
553
+ .sd-r-markdown h4, .sd-r-markdown h5, .sd-r-markdown h6 { font-size: 1em !important; }
554
+ .sd-r-markdown strong { font-weight: 600 !important; }
555
+ .sd-r-markdown em { font-style: italic !important; }
556
+ .sd-r-markdown u { text-decoration: underline !important; }
557
+ .sd-r-markdown ul, .sd-r-markdown ol { margin: 8px 0 !important; padding-left: 20px !important; }
558
+ .sd-r-markdown li { margin: 4px 0 !important; }
559
+ .sd-r-markdown blockquote { margin: 8px 0 !important; padding-left: 12px !important; border-left: 3px solid currentColor !important; opacity: 0.8 !important; }
560
+ .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; }
561
+ .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; }
562
+ .sd-r-markdown pre code { background: none !important; padding: 0 !important; }
563
+ .sd-r-markdown hr { border: none !important; border-top: 1px solid currentColor !important; opacity: 0.3 !important; margin: 10px 0 !important; }
564
+ .sd-r-markdown a { text-decoration: underline !important; opacity: 0.9 !important; }
565
+ .sd-r-markdown a:hover { opacity: 1 !important; }
566
+ .sd-r-markdown p { margin: 6px 0 !important; }
567
+ .sd-r-markdown > *:first-child { margin-top: 0 !important; }
568
+ .sd-r-markdown > *:last-child { margin-bottom: 0 !important; }
431
569
  @media (max-width: 420px) {
432
570
  .sd-r-panel {
433
571
  bottom: 0 !important; right: 0 !important; left: 0 !important;
@@ -514,7 +652,7 @@ var PoweredBy = ({ dark = false }) => /* @__PURE__ */ jsxs("div", { style: {
514
652
  {
515
653
  href: "https://github.com/mzeeshanwahid/sagedesk",
516
654
  target: "_blank",
517
- rel: "noopener noreferrer",
655
+ rel: "noopener",
518
656
  style: {
519
657
  color: dark ? "rgba(255,255,255,0.7)" : "#5a5a64",
520
658
  fontWeight: 500,
@@ -526,6 +664,7 @@ var PoweredBy = ({ dark = false }) => /* @__PURE__ */ jsxs("div", { style: {
526
664
  ] });
527
665
  function ClassicMessageBubble({ msg, accent }) {
528
666
  const isBot = msg.role === "bot";
667
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
529
668
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", alignItems: isBot ? "flex-start" : "flex-end", gap: "4px" }, children: [
530
669
  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" }),
531
670
  /* @__PURE__ */ jsx("div", { style: {
@@ -538,10 +677,9 @@ function ClassicMessageBubble({ msg, accent }) {
538
677
  color: isBot ? "#1a1a2e" : "#fff",
539
678
  border: isBot ? "1px solid rgba(20,20,40,0.06)" : "none",
540
679
  boxShadow: isBot ? "0 1px 2px rgba(20,20,40,0.04)" : `0 6px 16px -6px color-mix(in oklab, ${accent} 60%, transparent)`,
541
- whiteSpace: "pre-wrap",
542
680
  wordBreak: "break-word",
543
681
  fontFamily: "inherit"
544
- }, children: msg.text }),
682
+ }, className: "sd-r-markdown", children: isBot ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) : /* @__PURE__ */ jsx("div", { style: { whiteSpace: "pre-wrap" }, children: msg.text }) }),
545
683
  /* @__PURE__ */ jsx("span", { style: {
546
684
  fontSize: "11px",
547
685
  color: "#a8a8b0",
@@ -571,6 +709,7 @@ function ClassicTypingIndicator() {
571
709
  }
572
710
  function LightMessageBubble({ msg, accent, agentName }) {
573
711
  const isBot = msg.role === "bot";
712
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
574
713
  if (isBot) {
575
714
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "10px" }, children: [
576
715
  /* @__PURE__ */ jsx("div", { style: {
@@ -587,7 +726,7 @@ function LightMessageBubble({ msg, accent, agentName }) {
587
726
  /* @__PURE__ */ jsx("span", { style: { fontSize: "11px", color: "#a8a89e", fontVariantNumeric: "tabular-nums", fontFamily: "inherit" }, children: "just now" })
588
727
  ] }),
589
728
  msg.isFallback && /* @__PURE__ */ jsx("p", { style: { fontSize: "11px", color: "#9b9aa3", margin: "0 0 4px", fontFamily: "inherit" }, children: "Not sure about that one" }),
590
- /* @__PURE__ */ jsx("div", { style: { fontSize: "14px", lineHeight: 1.55, color: "#2a2a36", fontFamily: "inherit", whiteSpace: "pre-wrap", wordBreak: "break-word" }, children: msg.text })
729
+ /* @__PURE__ */ jsx("div", { style: { fontSize: "14px", lineHeight: 1.55, color: "#2a2a36", fontFamily: "inherit", wordBreak: "break-word" }, className: "sd-r-markdown", children: /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) })
591
730
  ] })
592
731
  ] });
593
732
  }
@@ -635,6 +774,7 @@ function LightTypingIndicator({ accent }) {
635
774
  }
636
775
  function DarkMessageBubble({ msg, accent }) {
637
776
  const isBot = msg.role === "bot";
777
+ const renderedHtml = isBot ? parseMarkdown(msg.text) : msg.text;
638
778
  return /* @__PURE__ */ jsxs("div", { style: {
639
779
  maxWidth: "85%",
640
780
  padding: "12px 14px",
@@ -646,12 +786,11 @@ function DarkMessageBubble({ msg, accent }) {
646
786
  color: isBot ? "rgba(255,255,255,0.92)" : "#fff",
647
787
  alignSelf: isBot ? "flex-start" : "flex-end",
648
788
  boxShadow: isBot ? "none" : `0 8px 20px -8px color-mix(in oklab, ${accent} 70%, transparent)`,
649
- whiteSpace: "pre-wrap",
650
789
  wordBreak: "break-word",
651
790
  fontFamily: "inherit"
652
- }, children: [
791
+ }, className: isBot ? "sd-r-markdown" : "", children: [
653
792
  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" }),
654
- msg.text
793
+ isBot ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: renderedHtml } }) : /* @__PURE__ */ jsx("div", { style: { whiteSpace: "pre-wrap" }, children: msg.text })
655
794
  ] });
656
795
  }
657
796
  function DarkTypingIndicator() {
@@ -1271,16 +1410,22 @@ function renderDark(p) {
1271
1410
  ] })
1272
1411
  ] });
1273
1412
  }
1274
- function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1275
- if (!indexUrl) {
1413
+ function SageDeskWidget({ mode, indexUrl, endpoint, agent, search: search2 }) {
1414
+ const resolvedMode = mode ?? "local";
1415
+ if (!agent?.name) {
1416
+ throw new Error('[sagedesk] Required prop "agent.name" is missing.');
1417
+ }
1418
+ if (resolvedMode === "local" && !indexUrl) {
1276
1419
  throw new Error(
1277
- '[sagedesk] Required prop "indexUrl" is missing. Run `npx sagedesk build` and pass the output path, e.g. indexUrl="/support-index.json".'
1420
+ '[sagedesk] Required prop "indexUrl" is missing for local mode. Run `npx sagedesk build` and pass the output path, e.g. indexUrl="/support-index.json".'
1278
1421
  );
1279
1422
  }
1280
- if (!agent?.name) {
1281
- throw new Error('[sagedesk] Required prop "agent.name" is missing.');
1423
+ if (resolvedMode === "llm" && !endpoint) {
1424
+ throw new Error(
1425
+ '[sagedesk] Required prop "endpoint" is missing for llm mode. Provide your backend route, e.g. endpoint="/api/sagedesk".'
1426
+ );
1282
1427
  }
1283
- const config = { indexUrl, agent, search: search2 };
1428
+ const config = { mode: resolvedMode, indexUrl, endpoint, agent, search: search2 };
1284
1429
  const { state, chips, open, close, submit } = useSageDesk(config);
1285
1430
  const theme = agent.theme ?? "classic";
1286
1431
  const accent = agent.accentColor ?? "#534AB7";
@@ -1293,7 +1438,7 @@ function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1293
1438
  const triggerRef = useRef2(null);
1294
1439
  const [mounted, setMounted] = useState2(false);
1295
1440
  useEffect2(() => {
1296
- if (!indexUrl.startsWith("/") && !indexUrl.startsWith("http")) {
1441
+ if (resolvedMode === "local" && indexUrl && !indexUrl.startsWith("/") && !indexUrl.startsWith("http")) {
1297
1442
  console.warn(
1298
1443
  `[sagedesk] indexUrl "${indexUrl}" looks like a relative path. It should start with "/" so it resolves correctly from any page.`
1299
1444
  );
@@ -1335,7 +1480,7 @@ function SageDeskWidget({ indexUrl, agent, search: search2 }) {
1335
1480
  [handleSubmit]
1336
1481
  );
1337
1482
  if (!mounted || typeof document === "undefined") return null;
1338
- const showPoweredBy = agent.poweredBy !== false;
1483
+ const showPoweredBy = true;
1339
1484
  const showChips = chips.length > 0;
1340
1485
  const panelClass = isClosing ? "sd-r-closing" : state.isOpen ? "sd-r-opening" : "";
1341
1486
  const props = {