@farming-labs/theme 0.1.71 → 0.1.73

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.
@@ -17,7 +17,8 @@ declare function DocsSearchDialog({
17
17
  loadingComponentHtml,
18
18
  models,
19
19
  defaultModelId,
20
- analytics
20
+ analytics,
21
+ feedbackEnabled
21
22
  }: {
22
23
  open: boolean;
23
24
  onOpenChange: (open: boolean) => void;
@@ -29,6 +30,7 @@ declare function DocsSearchDialog({
29
30
  models?: AIModelOption[];
30
31
  defaultModelId?: string;
31
32
  analytics?: boolean;
33
+ feedbackEnabled?: boolean;
32
34
  }): react.ReactPortal | null;
33
35
  type FloatingPosition = "bottom-right" | "bottom-left" | "bottom-center";
34
36
  type FloatingStyle = "panel" | "modal" | "popover" | "full-modal";
@@ -43,7 +45,8 @@ declare function FloatingAIChat({
43
45
  loadingComponentHtml,
44
46
  models,
45
47
  defaultModelId,
46
- analytics
48
+ analytics,
49
+ feedbackEnabled
47
50
  }: {
48
51
  api?: string;
49
52
  position?: FloatingPosition;
@@ -56,6 +59,7 @@ declare function FloatingAIChat({
56
59
  models?: AIModelOption[];
57
60
  defaultModelId?: string;
58
61
  analytics?: boolean;
62
+ feedbackEnabled?: boolean;
59
63
  }): react_jsx_runtime0.JSX.Element | null;
60
64
  declare function AIModalDialog({
61
65
  open,
@@ -67,7 +71,8 @@ declare function AIModalDialog({
67
71
  loadingComponentHtml,
68
72
  models,
69
73
  defaultModelId,
70
- analytics
74
+ analytics,
75
+ feedbackEnabled
71
76
  }: {
72
77
  open: boolean;
73
78
  onOpenChange: (open: boolean) => void;
@@ -79,6 +84,7 @@ declare function AIModalDialog({
79
84
  models?: AIModelOption[];
80
85
  defaultModelId?: string;
81
86
  analytics?: boolean;
87
+ feedbackEnabled?: boolean;
82
88
  }): react.ReactPortal | null;
83
89
  //#endregion
84
90
  export { AIModalDialog, DocsSearchDialog, FloatingAIChat };
@@ -18,6 +18,118 @@ import { highlight } from "sugar-high";
18
18
  * - `mode="search"` (default): AI tab inside the Cmd+K search dialog
19
19
  * - `mode="floating"`: Standalone floating chat widget with configurable position
20
20
  */
21
+ let aiMessageId = 0;
22
+ function createAIMessageId() {
23
+ aiMessageId += 1;
24
+ return `ai_${Date.now().toString(36)}_${aiMessageId.toString(36)}`;
25
+ }
26
+ function getLastUserQuestion(messages, assistantIndex) {
27
+ for (let i = assistantIndex - 1; i >= 0; i -= 1) {
28
+ const message = messages[i];
29
+ if (message?.role === "user") return message.content;
30
+ }
31
+ return "";
32
+ }
33
+ function buildActionPayload(options) {
34
+ const location = typeof window !== "undefined" ? {
35
+ url: window.location.href,
36
+ path: window.location.pathname
37
+ } : {};
38
+ return {
39
+ type: options.type,
40
+ value: options.type === "like" || options.type === "dislike" ? options.type : void 0,
41
+ question: getLastUserQuestion(options.messages, options.index),
42
+ answer: options.message.content,
43
+ messageId: options.message.id,
44
+ messageIndex: options.index,
45
+ model: options.message.model,
46
+ surface: options.surface,
47
+ messages: options.messages.slice(0, options.index + 1).map((message) => ({
48
+ role: message.role,
49
+ content: message.content
50
+ })),
51
+ copied: options.copied,
52
+ ...location
53
+ };
54
+ }
55
+ function toFeedbackPayload(data) {
56
+ if (data.type !== "like" && data.type !== "dislike") return null;
57
+ return {
58
+ value: data.type,
59
+ question: data.question,
60
+ answer: data.answer,
61
+ messageId: data.messageId,
62
+ messageIndex: data.messageIndex,
63
+ model: data.model,
64
+ surface: data.surface,
65
+ url: data.url,
66
+ path: data.path,
67
+ messages: data.messages
68
+ };
69
+ }
70
+ function emitAskAIAction(data) {
71
+ if (typeof window === "undefined") return;
72
+ try {
73
+ const result = window.__fdOnAIActions__?.(data);
74
+ if (result && typeof result.catch === "function") result.catch(() => {});
75
+ } catch {}
76
+ try {
77
+ window.dispatchEvent(new CustomEvent("fd:ai-action", { detail: data }));
78
+ } catch {}
79
+ }
80
+ function emitAskAIFeedback(data, analytics) {
81
+ if (analytics) emitClientAnalyticsEvent({
82
+ type: "ai_feedback",
83
+ input: {
84
+ question: data.question,
85
+ feedbackValue: data.value
86
+ },
87
+ properties: {
88
+ value: data.value,
89
+ surface: data.surface,
90
+ model: data.model,
91
+ questionLength: data.question.length,
92
+ answerLength: data.answer.length,
93
+ messageIndex: data.messageIndex
94
+ }
95
+ });
96
+ if (typeof window === "undefined") return;
97
+ try {
98
+ const result = window.__fdOnAIFeedback__?.(data);
99
+ if (result && typeof result.catch === "function") result.catch(() => {});
100
+ } catch {}
101
+ try {
102
+ window.dispatchEvent(new CustomEvent("fd:ai-feedback", { detail: data }));
103
+ } catch {}
104
+ }
105
+ function fallbackCopyText(text) {
106
+ if (typeof document === "undefined") return false;
107
+ const textarea = document.createElement("textarea");
108
+ textarea.value = text;
109
+ textarea.setAttribute("readonly", "");
110
+ textarea.style.position = "fixed";
111
+ textarea.style.top = "-9999px";
112
+ textarea.style.opacity = "0";
113
+ document.body.appendChild(textarea);
114
+ textarea.select();
115
+ try {
116
+ document.execCommand("copy");
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ } finally {
121
+ document.body.removeChild(textarea);
122
+ }
123
+ }
124
+ async function copyTextToClipboard(text) {
125
+ if (!text) return false;
126
+ try {
127
+ await navigator.clipboard.writeText(text);
128
+ return true;
129
+ } catch {
130
+ return fallbackCopyText(text);
131
+ }
132
+ }
21
133
  function buildCodeBlock(lang, code) {
22
134
  const highlighted = highlight(code.replace(/\n$/, "")).replace(/<\/span>\n<span/g, "</span><span");
23
135
  return `<div class="fd-ai-code-block"><div class="fd-ai-code-header">${lang ? `<div class="fd-ai-code-lang">${escapeHtml(lang)}</div>` : ""}<button class="fd-ai-code-copy" onclick="(function(btn){var code=btn.closest('.fd-ai-code-block').querySelector('code').textContent;navigator.clipboard.writeText(code).then(function(){btn.textContent='Copied!';setTimeout(function(){btn.textContent='Copy'},1500)})})(this)">Copy</button></div><pre><code>${highlighted}</code></pre></div>`;
@@ -146,6 +258,65 @@ function XIcon() {
146
258
  children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
147
259
  });
148
260
  }
261
+ function ThumbsUpIcon() {
262
+ return /* @__PURE__ */ jsxs("svg", {
263
+ width: "14",
264
+ height: "14",
265
+ viewBox: "0 0 24 24",
266
+ fill: "none",
267
+ stroke: "currentColor",
268
+ strokeWidth: "2",
269
+ strokeLinecap: "round",
270
+ strokeLinejoin: "round",
271
+ children: [/* @__PURE__ */ jsx("path", { d: "M7 10v12" }), /* @__PURE__ */ jsx("path", { d: "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" })]
272
+ });
273
+ }
274
+ function ThumbsDownIcon() {
275
+ return /* @__PURE__ */ jsxs("svg", {
276
+ width: "14",
277
+ height: "14",
278
+ viewBox: "0 0 24 24",
279
+ fill: "none",
280
+ stroke: "currentColor",
281
+ strokeWidth: "2",
282
+ strokeLinecap: "round",
283
+ strokeLinejoin: "round",
284
+ children: [/* @__PURE__ */ jsx("path", { d: "M17 14V2" }), /* @__PURE__ */ jsx("path", { d: "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" })]
285
+ });
286
+ }
287
+ function CopyIcon() {
288
+ return /* @__PURE__ */ jsxs("svg", {
289
+ width: "14",
290
+ height: "14",
291
+ viewBox: "0 0 24 24",
292
+ fill: "none",
293
+ stroke: "currentColor",
294
+ strokeWidth: "2",
295
+ strokeLinecap: "round",
296
+ strokeLinejoin: "round",
297
+ children: [/* @__PURE__ */ jsx("rect", {
298
+ x: "9",
299
+ y: "9",
300
+ width: "13",
301
+ height: "13",
302
+ rx: "2",
303
+ ry: "2"
304
+ }), /* @__PURE__ */ jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })]
305
+ });
306
+ }
307
+ function CheckIcon() {
308
+ return /* @__PURE__ */ jsx("svg", {
309
+ width: "14",
310
+ height: "14",
311
+ viewBox: "0 0 24 24",
312
+ fill: "none",
313
+ stroke: "currentColor",
314
+ strokeWidth: "2.4",
315
+ strokeLinecap: "round",
316
+ strokeLinejoin: "round",
317
+ children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
318
+ });
319
+ }
149
320
  function LoaderIndicator({ variant = "shimmer-dots" }) {
150
321
  const text = "Thinking";
151
322
  switch (variant) {
@@ -346,7 +517,51 @@ function ModelSelector({ models, selectedId, onChange, disabled }) {
346
517
  })]
347
518
  });
348
519
  }
349
- function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, surface = "chat" }) {
520
+ function AIFeedbackControls({ value, onCopy, onSelect }) {
521
+ const [copied, setCopied] = useState(false);
522
+ const handleCopy = useCallback(async () => {
523
+ if (!await onCopy()) return;
524
+ setCopied(true);
525
+ window.setTimeout(() => setCopied(false), 1500);
526
+ }, [onCopy]);
527
+ return /* @__PURE__ */ jsxs("div", {
528
+ className: "fd-ai-feedback",
529
+ role: "group",
530
+ "aria-label": "Rate this Ask AI response",
531
+ children: [
532
+ /* @__PURE__ */ jsx("button", {
533
+ type: "button",
534
+ className: "fd-ai-feedback-btn",
535
+ "data-copied": copied ? "true" : void 0,
536
+ "aria-label": copied ? "Copied response" : "Copy response",
537
+ title: copied ? "Copied" : "Copy response",
538
+ onClick: handleCopy,
539
+ children: copied ? /* @__PURE__ */ jsx(CheckIcon, {}) : /* @__PURE__ */ jsx(CopyIcon, {})
540
+ }),
541
+ /* @__PURE__ */ jsx("button", {
542
+ type: "button",
543
+ className: "fd-ai-feedback-btn",
544
+ "data-active": value === "like",
545
+ "aria-pressed": value === "like",
546
+ "aria-label": "Helpful",
547
+ title: "Helpful",
548
+ onClick: () => onSelect("like"),
549
+ children: /* @__PURE__ */ jsx(ThumbsUpIcon, {})
550
+ }),
551
+ /* @__PURE__ */ jsx("button", {
552
+ type: "button",
553
+ className: "fd-ai-feedback-btn",
554
+ "data-active": value === "dislike",
555
+ "aria-pressed": value === "dislike",
556
+ "aria-label": "Not helpful",
557
+ title: "Not helpful",
558
+ onClick: () => onSelect("dislike"),
559
+ children: /* @__PURE__ */ jsx(ThumbsDownIcon, {})
560
+ })
561
+ ]
562
+ });
563
+ }
564
+ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled = true, surface = "chat" }) {
350
565
  const label = aiLabel || "AI";
351
566
  const aiInputRef = useRef(null);
352
567
  const messagesEndRef = useRef(null);
@@ -368,12 +583,15 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
368
583
  content: question
369
584
  };
370
585
  const newMessages = [...messages, userMessage];
586
+ const assistantMessage = {
587
+ role: "assistant",
588
+ content: "",
589
+ id: createAIMessageId(),
590
+ model: effectiveModelId
591
+ };
371
592
  setAiInput("");
372
593
  setIsStreaming(true);
373
- setMessages([...newMessages, {
374
- role: "assistant",
375
- content: ""
376
- }]);
594
+ setMessages([...newMessages, assistantMessage]);
377
595
  const startedAt = Date.now();
378
596
  if (analytics) emitClientAnalyticsEvent({
379
597
  type: "ai_question",
@@ -402,8 +620,9 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
402
620
  errMsg = (await res.json()).error || errMsg;
403
621
  } catch {}
404
622
  setMessages([...newMessages, {
405
- role: "assistant",
406
- content: errMsg
623
+ ...assistantMessage,
624
+ content: errMsg,
625
+ isError: true
407
626
  }]);
408
627
  setIsStreaming(false);
409
628
  if (analytics) emitClientAnalyticsEvent({
@@ -435,7 +654,7 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
435
654
  if (content) {
436
655
  assistantContent += content;
437
656
  setMessages([...newMessages, {
438
- role: "assistant",
657
+ ...assistantMessage,
439
658
  content: assistantContent
440
659
  }]);
441
660
  }
@@ -443,7 +662,7 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
443
662
  }
444
663
  }
445
664
  if (assistantContent) setMessages([...newMessages, {
446
- role: "assistant",
665
+ ...assistantMessage,
447
666
  content: assistantContent
448
667
  }]);
449
668
  if (analytics) emitClientAnalyticsEvent({
@@ -458,8 +677,9 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
458
677
  });
459
678
  } catch {
460
679
  setMessages([...newMessages, {
461
- role: "assistant",
462
- content: "Failed to connect. Please try again."
680
+ ...assistantMessage,
681
+ content: "Failed to connect. Please try again.",
682
+ isError: true
463
683
  }]);
464
684
  if (analytics) emitClientAnalyticsEvent({
465
685
  type: "ai_error",
@@ -492,6 +712,42 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
492
712
  }
493
713
  };
494
714
  const canSend = !!(aiInput.trim() && !isStreaming);
715
+ const handleFeedback = useCallback((message, index, value) => {
716
+ if (message.feedback === value) return;
717
+ const updatedMessage = {
718
+ ...message,
719
+ feedback: value
720
+ };
721
+ const updatedMessages = messages.map((item, itemIndex) => itemIndex === index ? updatedMessage : item);
722
+ setMessages(updatedMessages);
723
+ const actionPayload = buildActionPayload({
724
+ type: value,
725
+ message: updatedMessage,
726
+ messages: updatedMessages,
727
+ index,
728
+ surface
729
+ });
730
+ emitAskAIAction(actionPayload);
731
+ const feedbackPayload = toFeedbackPayload(actionPayload);
732
+ if (feedbackPayload) emitAskAIFeedback(feedbackPayload, analytics);
733
+ }, [
734
+ analytics,
735
+ messages,
736
+ setMessages,
737
+ surface
738
+ ]);
739
+ const handleCopyMessage = useCallback(async (message, index) => {
740
+ const copied = await copyTextToClipboard(message.content);
741
+ emitAskAIAction(buildActionPayload({
742
+ type: "copy",
743
+ message,
744
+ messages,
745
+ index,
746
+ surface,
747
+ copied
748
+ }));
749
+ return copied;
750
+ }, [messages, surface]);
495
751
  return /* @__PURE__ */ jsxs("div", {
496
752
  style: {
497
753
  display: "flex",
@@ -536,10 +792,14 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
536
792
  children: msg.content
537
793
  }) : /* @__PURE__ */ jsx("div", {
538
794
  className: "fd-ai-bubble-ai",
539
- children: msg.content ? /* @__PURE__ */ jsx("div", {
795
+ children: msg.content ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
540
796
  className: isStreaming && i === messages.length - 1 ? "fd-ai-streaming" : void 0,
541
797
  dangerouslySetInnerHTML: { __html: renderMarkdown(msg.content) }
542
- }) : loadingComponentHtml ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: loadingComponentHtml } }) : /* @__PURE__ */ jsx(LoaderIndicator, {
798
+ }), feedbackEnabled && !msg.isError && !isStreaming && /* @__PURE__ */ jsx(AIFeedbackControls, {
799
+ value: msg.feedback,
800
+ onCopy: () => handleCopyMessage(msg, i),
801
+ onSelect: (value) => handleFeedback(msg, i, value)
802
+ })] }) : loadingComponentHtml ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: loadingComponentHtml } }) : /* @__PURE__ */ jsx(LoaderIndicator, {
543
803
  variant: loaderVariant,
544
804
  label
545
805
  })
@@ -600,7 +860,7 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
600
860
  })]
601
861
  });
602
862
  }
603
- function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false }) {
863
+ function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
604
864
  const [tab, setTab] = useState("search");
605
865
  const [searchQuery, setSearchQuery] = useState("");
606
866
  const [searchResults, setSearchResults] = useState([]);
@@ -849,6 +1109,7 @@ function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQues
849
1109
  models,
850
1110
  defaultModelId: effectiveModelId,
851
1111
  analytics,
1112
+ feedbackEnabled,
852
1113
  surface: "ai-dialog"
853
1114
  })
854
1115
  ]
@@ -923,7 +1184,7 @@ function getContainerStyles(style, position) {
923
1184
  function getAnimation(style) {
924
1185
  return style === "modal" ? "fd-ai-float-center-in 200ms ease-out" : "fd-ai-float-in 200ms ease-out";
925
1186
  }
926
- function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false }) {
1187
+ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
927
1188
  const [mounted, setMounted] = useState(false);
928
1189
  const [isOpen, setIsOpen] = useState(false);
929
1190
  const [messages, setMessages] = useState([]);
@@ -980,7 +1241,8 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
980
1241
  position,
981
1242
  models,
982
1243
  defaultModelId,
983
- analytics
1244
+ analytics,
1245
+ feedbackEnabled
984
1246
  });
985
1247
  const btnPosition = BTN_POSITIONS[position] || BTN_POSITIONS["bottom-right"];
986
1248
  const isModal = floatingStyle === "modal";
@@ -1028,7 +1290,10 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
1028
1290
  aiLabel,
1029
1291
  loaderVariant,
1030
1292
  loadingComponentHtml,
1293
+ models,
1294
+ defaultModelId,
1031
1295
  analytics,
1296
+ feedbackEnabled,
1032
1297
  surface: "floating"
1033
1298
  })]
1034
1299
  }),
@@ -1064,7 +1329,7 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
1064
1329
  }))
1065
1330
  ] }), document.body);
1066
1331
  }
1067
- function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, triggerComponentHtml, position, models, defaultModelId, analytics }) {
1332
+ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, triggerComponentHtml, position, models, defaultModelId, analytics, feedbackEnabled = true }) {
1068
1333
  const label = aiLabel || "AI";
1069
1334
  const inputRef = useRef(null);
1070
1335
  const listRef = useRef(null);
@@ -1090,12 +1355,15 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1090
1355
  content: question
1091
1356
  };
1092
1357
  const newMessages = [...messages, userMessage];
1358
+ const assistantMessage = {
1359
+ role: "assistant",
1360
+ content: "",
1361
+ id: createAIMessageId(),
1362
+ model: effectiveModelId
1363
+ };
1093
1364
  setAiInput("");
1094
1365
  setIsStreaming(true);
1095
- setMessages([...newMessages, {
1096
- role: "assistant",
1097
- content: ""
1098
- }]);
1366
+ setMessages([...newMessages, assistantMessage]);
1099
1367
  const startedAt = Date.now();
1100
1368
  if (analytics) emitClientAnalyticsEvent({
1101
1369
  type: "ai_question",
@@ -1124,8 +1392,9 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1124
1392
  errMsg = (await res.json()).error || errMsg;
1125
1393
  } catch {}
1126
1394
  setMessages([...newMessages, {
1127
- role: "assistant",
1128
- content: errMsg
1395
+ ...assistantMessage,
1396
+ content: errMsg,
1397
+ isError: true
1129
1398
  }]);
1130
1399
  setIsStreaming(false);
1131
1400
  if (analytics) emitClientAnalyticsEvent({
@@ -1157,7 +1426,7 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1157
1426
  if (content) {
1158
1427
  assistantContent += content;
1159
1428
  setMessages([...newMessages, {
1160
- role: "assistant",
1429
+ ...assistantMessage,
1161
1430
  content: assistantContent
1162
1431
  }]);
1163
1432
  }
@@ -1165,7 +1434,7 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1165
1434
  }
1166
1435
  }
1167
1436
  if (assistantContent) setMessages([...newMessages, {
1168
- role: "assistant",
1437
+ ...assistantMessage,
1169
1438
  content: assistantContent
1170
1439
  }]);
1171
1440
  if (analytics) emitClientAnalyticsEvent({
@@ -1180,8 +1449,9 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1180
1449
  });
1181
1450
  } catch {
1182
1451
  setMessages([...newMessages, {
1183
- role: "assistant",
1184
- content: "Failed to connect. Please try again."
1452
+ ...assistantMessage,
1453
+ content: "Failed to connect. Please try again.",
1454
+ isError: true
1185
1455
  }]);
1186
1456
  if (analytics) emitClientAnalyticsEvent({
1187
1457
  type: "ai_error",
@@ -1205,6 +1475,41 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1205
1475
  ]);
1206
1476
  const canSend = !!(aiInput.trim() && !isStreaming);
1207
1477
  const showSuggestions = messages.length === 0 && !isStreaming;
1478
+ const handleFeedback = useCallback((message, index, value) => {
1479
+ if (message.feedback === value) return;
1480
+ const updatedMessage = {
1481
+ ...message,
1482
+ feedback: value
1483
+ };
1484
+ const updatedMessages = messages.map((item, itemIndex) => itemIndex === index ? updatedMessage : item);
1485
+ setMessages(updatedMessages);
1486
+ const actionPayload = buildActionPayload({
1487
+ type: value,
1488
+ message: updatedMessage,
1489
+ messages: updatedMessages,
1490
+ index,
1491
+ surface: "full-modal"
1492
+ });
1493
+ emitAskAIAction(actionPayload);
1494
+ const feedbackPayload = toFeedbackPayload(actionPayload);
1495
+ if (feedbackPayload) emitAskAIFeedback(feedbackPayload, analytics);
1496
+ }, [
1497
+ analytics,
1498
+ messages,
1499
+ setMessages
1500
+ ]);
1501
+ const handleCopyMessage = useCallback(async (message, index) => {
1502
+ const copied = await copyTextToClipboard(message.content);
1503
+ emitAskAIAction(buildActionPayload({
1504
+ type: "copy",
1505
+ message,
1506
+ messages,
1507
+ index,
1508
+ surface: "full-modal",
1509
+ copied
1510
+ }));
1511
+ return copied;
1512
+ }, [messages]);
1208
1513
  const handleKeyDown = (e) => {
1209
1514
  if (e.key === "Enter" && !e.shiftKey) {
1210
1515
  e.preventDefault();
@@ -1237,10 +1542,14 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1237
1542
  children: msg.role === "user" ? "you" : label
1238
1543
  }), /* @__PURE__ */ jsx("div", {
1239
1544
  className: "fd-ai-fm-msg-content",
1240
- children: msg.content ? /* @__PURE__ */ jsx("div", {
1545
+ children: msg.content ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
1241
1546
  className: isStreaming && i === messages.length - 1 ? "fd-ai-streaming" : void 0,
1242
1547
  dangerouslySetInnerHTML: { __html: renderMarkdown(msg.content) }
1243
- }) : loadingComponentHtml ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: loadingComponentHtml } }) : /* @__PURE__ */ jsx(LoaderIndicator, {
1548
+ }), msg.role === "assistant" && feedbackEnabled && !msg.isError && !isStreaming && /* @__PURE__ */ jsx(AIFeedbackControls, {
1549
+ value: msg.feedback,
1550
+ onCopy: () => handleCopyMessage(msg, i),
1551
+ onSelect: (value) => handleFeedback(msg, i, value)
1552
+ })] }) : loadingComponentHtml ? /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: loadingComponentHtml } }) : /* @__PURE__ */ jsx(LoaderIndicator, {
1244
1553
  variant: loaderVariant,
1245
1554
  label
1246
1555
  })
@@ -1368,7 +1677,7 @@ function TrashIcon() {
1368
1677
  ]
1369
1678
  });
1370
1679
  }
1371
- function AIModalDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false }) {
1680
+ function AIModalDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1372
1681
  const [messages, setMessages] = useState([]);
1373
1682
  const [aiInput, setAiInput] = useState("");
1374
1683
  const [isStreaming, setIsStreaming] = useState(false);
@@ -1450,6 +1759,7 @@ function AIModalDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestio
1450
1759
  models,
1451
1760
  defaultModelId,
1452
1761
  analytics,
1762
+ feedbackEnabled,
1453
1763
  surface: "modal"
1454
1764
  }),
1455
1765
  /* @__PURE__ */ jsx("div", {
@@ -18,6 +18,7 @@ interface DocsAIFeaturesProps {
18
18
  }[];
19
19
  defaultModelId?: string;
20
20
  analytics?: boolean;
21
+ feedbackEnabled?: boolean;
21
22
  }
22
23
  declare function DocsAIFeatures({
23
24
  mode,
@@ -32,7 +33,8 @@ declare function DocsAIFeatures({
32
33
  loadingComponentHtml,
33
34
  models,
34
35
  defaultModelId,
35
- analytics
36
+ analytics,
37
+ feedbackEnabled
36
38
  }: DocsAIFeaturesProps): react_jsx_runtime0.JSX.Element;
37
39
  //#endregion
38
40
  export { DocsAIFeatures };
@@ -22,7 +22,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
22
22
  * This component is rendered inside the docs layout so the user's root layout
23
23
  * never needs to be modified — AI features work purely from `docs.config.ts`.
24
24
  */
25
- function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false }) {
25
+ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
26
26
  const localizedApi = withLangInUrl(api, resolveClientLocale(useWindowSearchParams(), locale));
27
27
  if (mode === "search") return /* @__PURE__ */ jsx(SearchModeAI, {
28
28
  api: localizedApi,
@@ -32,7 +32,8 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
32
32
  loadingComponentHtml,
33
33
  models,
34
34
  defaultModelId,
35
- analytics
35
+ analytics,
36
+ feedbackEnabled
36
37
  });
37
38
  if (mode === "sidebar-icon") return /* @__PURE__ */ jsx(SidebarIconModeAI, {
38
39
  api: localizedApi,
@@ -42,7 +43,8 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
42
43
  loadingComponentHtml,
43
44
  models,
44
45
  defaultModelId,
45
- analytics
46
+ analytics,
47
+ feedbackEnabled
46
48
  });
47
49
  return /* @__PURE__ */ jsx(FloatingAIChat, {
48
50
  api: localizedApi,
@@ -55,10 +57,11 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
55
57
  loadingComponentHtml,
56
58
  models,
57
59
  defaultModelId,
58
- analytics
60
+ analytics,
61
+ feedbackEnabled
59
62
  });
60
63
  }
61
- function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics }) {
64
+ function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
62
65
  const [open, setOpen] = useState(false);
63
66
  useEffect(() => {
64
67
  function handler(e) {
@@ -111,10 +114,11 @@ function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loading
111
114
  loadingComponentHtml,
112
115
  models,
113
116
  defaultModelId,
114
- analytics
117
+ analytics,
118
+ feedbackEnabled
115
119
  });
116
120
  }
117
- function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics }) {
121
+ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
118
122
  const [searchOpen, setSearchOpen] = useState(false);
119
123
  const [aiOpen, setAiOpen] = useState(false);
120
124
  useEffect(() => {
@@ -167,7 +171,8 @@ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, lo
167
171
  loadingComponentHtml,
168
172
  models,
169
173
  defaultModelId,
170
- analytics
174
+ analytics,
175
+ feedbackEnabled
171
176
  }), /* @__PURE__ */ jsx(AIModalDialog, {
172
177
  open: aiOpen,
173
178
  onOpenChange: setAiOpen,
@@ -178,7 +183,8 @@ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, lo
178
183
  loadingComponentHtml,
179
184
  models,
180
185
  defaultModelId,
181
- analytics
186
+ analytics,
187
+ feedbackEnabled
182
188
  })] });
183
189
  }
184
190
 
@@ -1,4 +1,4 @@
1
- import { ChangelogConfig, DocsAnalyticsConfig, DocsI18nConfig, DocsMcpConfig, DocsSearchConfig, FeedbackConfig, OrderingItem } from "@farming-labs/docs";
1
+ import { ChangelogConfig, DocsAnalyticsConfig, DocsI18nConfig, DocsMcpConfig, DocsObservabilityConfig, DocsSearchConfig, FeedbackConfig, OrderingItem } from "@farming-labs/docs";
2
2
 
3
3
  //#region src/docs-api.d.ts
4
4
  interface AIProviderConfig {
@@ -19,6 +19,10 @@ interface AIOptions {
19
19
  model?: string | AIModelConfig;
20
20
  providers?: Record<string, AIProviderConfig>;
21
21
  systemPrompt?: string;
22
+ /** Package name the AI should use in import examples. */
23
+ packageName?: string;
24
+ /** Public docs URL the AI should use for absolute links. */
25
+ docsUrl?: string;
22
26
  /** Default baseUrl when no per-model provider is configured. */
23
27
  baseUrl?: string;
24
28
  /** Default apiKey when no per-model provider is configured. */
@@ -43,6 +47,8 @@ interface DocsAPIOptions {
43
47
  search?: boolean | DocsSearchConfig;
44
48
  /** Analytics configuration */
45
49
  analytics?: boolean | DocsAnalyticsConfig;
50
+ /** Observability configuration for logs, traces, and metrics callbacks. */
51
+ observability?: boolean | DocsObservabilityConfig;
46
52
  /** Feedback configuration */
47
53
  feedback?: boolean | FeedbackConfig;
48
54
  /** MCP configuration used for the agent discovery spec. */
@@ -59,6 +65,7 @@ interface DocsMCPAPIOptions {
59
65
  mcp?: boolean | DocsMcpConfig;
60
66
  search?: boolean | DocsSearchConfig;
61
67
  analytics?: boolean | DocsAnalyticsConfig;
68
+ observability?: boolean | DocsObservabilityConfig;
62
69
  }
63
70
  /**
64
71
  * Create a unified docs API route handler.
package/dist/docs-api.mjs CHANGED
@@ -3,7 +3,7 @@ import { getNextAppDir } from "./get-app-dir.mjs";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import matter from "gray-matter";
6
- import { emitDocsAnalyticsEvent, normalizeDocsRelated, performDocsSearch, renderDocsRelatedMarkdownLines, resolveChangelogConfig, resolveDocsI18n, resolveDocsLocale, resolvePageSidebarFolderIndexBehavior, resolveSearchRequestConfig } from "@farming-labs/docs";
6
+ import { buildDocsAskAIContext, createDocsAgentTraceContext, createDocsAgentTraceId, emitDocsAgentTraceEvent, emitDocsAnalyticsEvent, formatDocsAskAIPackageHints, normalizeDocsRelated, performDocsSearch, renderDocsRelatedMarkdownLines, resolveChangelogConfig, resolveDocsI18n, resolveDocsLocale, resolvePageSidebarFolderIndexBehavior, resolveSearchRequestConfig } from "@farming-labs/docs";
7
7
  import { createDocsMcpHttpHandler, createFilesystemDocsMcpSource, resolveDocsMcpConfig } from "@farming-labs/docs/server";
8
8
 
9
9
  //#region src/docs-api.ts
@@ -414,13 +414,17 @@ function readAIConfig(root) {
414
414
  const apiKeyMatch = content.match(/ai\s*:\s*\{[^}]*apiKey\s*:\s*process\.env\.(\w+)/s);
415
415
  const maxResultsMatch = content.match(/ai\s*:\s*\{[^}]*maxResults\s*:\s*(\d+)/s);
416
416
  const systemPromptMatch = content.match(/ai\s*:\s*\{[^}]*systemPrompt\s*:\s*["'`]([^"'`]+)["'`]/s);
417
+ const packageNameMatch = content.match(/ai\s*:\s*\{[^}]*packageName\s*:\s*["'`]([^"'`]+)["'`]/s);
418
+ const docsUrlMatch = content.match(/ai\s*:\s*\{[^}]*docsUrl\s*:\s*["'`]([^"'`]+)["'`]/s);
417
419
  return {
418
420
  enabled: true,
419
421
  model: modelMatch?.[1],
420
422
  baseUrl: baseUrlMatch?.[1],
421
423
  apiKey: apiKeyMatch?.[1] ? process.env[apiKeyMatch[1]] : void 0,
422
424
  maxResults: maxResultsMatch ? parseInt(maxResultsMatch[1], 10) : void 0,
423
- systemPrompt: systemPromptMatch?.[1]
425
+ systemPrompt: systemPromptMatch?.[1],
426
+ packageName: packageNameMatch?.[1],
427
+ docsUrl: docsUrlMatch?.[1]
424
428
  };
425
429
  } catch {}
426
430
  }
@@ -936,7 +940,21 @@ function truncateSkillDescription(value) {
936
940
  function toYamlString(value) {
937
941
  return JSON.stringify(value);
938
942
  }
939
- const DEFAULT_SYSTEM_PROMPT = `You are a helpful documentation assistant. Answer questions based on the provided documentation context. Be concise and accurate. If the answer is not in the context, say so honestly. Use markdown formatting for code examples and links.`;
943
+ function buildDefaultSystemPrompt(aiConfig) {
944
+ const lines = [
945
+ "You are a helpful documentation assistant.",
946
+ "Answer only from the provided documentation context.",
947
+ "Prefer exact code/config snippets from the context when the question asks how to implement something.",
948
+ "Cite the relevant documentation URL when you use a source.",
949
+ "Use only URLs exactly as they appear in the context; do not invent placeholder domains.",
950
+ "Never use placeholder package names or imports such as \"your-auth-library\", \"your-package\", \"your-sdk\", \"replace-me\", or \"example-library\". If the exact package or import is not in the context, do not include an import snippet.",
951
+ "Be concise and accurate. If the answer is not in the context, say so honestly.",
952
+ "Use markdown formatting for code examples and links."
953
+ ];
954
+ if (aiConfig.packageName) lines.push(`When showing import examples, use "${aiConfig.packageName}" as the package name and prefer exact imports copied from the documentation context.`);
955
+ if (aiConfig.docsUrl) lines.push(`When linking to documentation pages, use "${aiConfig.docsUrl}" as the base URL (e.g. ${aiConfig.docsUrl}/docs/get-started).`);
956
+ return lines.join(" ");
957
+ }
940
958
  function resolveModelAndProvider(aiConfig, requestedModelId) {
941
959
  const raw = aiConfig.model;
942
960
  const modelList = typeof raw === "object" && raw?.models || [];
@@ -956,9 +974,74 @@ function resolveModelAndProvider(aiConfig, requestedModelId) {
956
974
  apiKey
957
975
  };
958
976
  }
959
- async function handleAskAI(request, indexes, aiConfig, analytics, analyticsContext = {}) {
977
+ function safeUrlOrigin(value) {
978
+ try {
979
+ return new URL(value).origin;
980
+ } catch {
981
+ return value;
982
+ }
983
+ }
984
+ async function handleAskAI(request, indexes, aiConfig, search, analytics, observability, analyticsContext = {}) {
960
985
  const url = new URL(request.url);
961
986
  const requestStartedAt = Date.now();
987
+ const trace = createDocsAgentTraceContext("ask-ai");
988
+ const runSpanId = createDocsAgentTraceId("span");
989
+ const traceBase = {
990
+ source: "server",
991
+ traceId: trace.traceId,
992
+ url: request.url,
993
+ path: url.pathname,
994
+ locale: analyticsContext.locale
995
+ };
996
+ async function emitTrace(event) {
997
+ await emitDocsAgentTraceEvent(observability, {
998
+ ...traceBase,
999
+ ...event
1000
+ });
1001
+ }
1002
+ async function emitRunError(reason, outputPreview = {}) {
1003
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
1004
+ const elapsed = Math.max(0, Date.now() - requestStartedAt);
1005
+ const common = {
1006
+ name: "ask-ai",
1007
+ startedAt: trace.startedAt,
1008
+ endedAt,
1009
+ durationMs: elapsed,
1010
+ status: "error",
1011
+ outputPreview: {
1012
+ reason,
1013
+ ...outputPreview
1014
+ },
1015
+ metadata: { reason }
1016
+ };
1017
+ await emitTrace({
1018
+ ...common,
1019
+ type: "error",
1020
+ parentSpanId: runSpanId
1021
+ });
1022
+ await emitTrace({
1023
+ ...common,
1024
+ type: "run.error",
1025
+ spanId: runSpanId
1026
+ });
1027
+ await emitTrace({
1028
+ ...common,
1029
+ type: "run.end",
1030
+ spanId: runSpanId
1031
+ });
1032
+ }
1033
+ await emitTrace({
1034
+ type: "run.start",
1035
+ name: "ask-ai",
1036
+ spanId: runSpanId,
1037
+ startedAt: trace.startedAt,
1038
+ durationMs: 0,
1039
+ status: "started",
1040
+ inputPreview: {
1041
+ method: request.method,
1042
+ path: url.pathname
1043
+ }
1044
+ });
962
1045
  let body;
963
1046
  try {
964
1047
  body = await request.json();
@@ -974,6 +1057,7 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
974
1057
  durationMs: Math.max(0, Date.now() - requestStartedAt)
975
1058
  }
976
1059
  });
1060
+ await emitRunError("invalid_json", { status: 400 });
977
1061
  return Response.json({ error: "Invalid JSON body. Expected { messages: [...] }" }, { status: 400 });
978
1062
  }
979
1063
  const messages = body.messages;
@@ -989,6 +1073,7 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
989
1073
  durationMs: Math.max(0, Date.now() - requestStartedAt)
990
1074
  }
991
1075
  });
1076
+ await emitRunError("missing_messages", { status: 400 });
992
1077
  return Response.json({ error: "messages array is required and must not be empty." }, { status: 400 });
993
1078
  }
994
1079
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
@@ -1005,27 +1090,101 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
1005
1090
  durationMs: Math.max(0, Date.now() - requestStartedAt)
1006
1091
  }
1007
1092
  });
1093
+ await emitRunError("missing_user_message", {
1094
+ status: 400,
1095
+ messageCount: messages.length
1096
+ });
1008
1097
  return Response.json({ error: "At least one user message is required." }, { status: 400 });
1009
1098
  }
1010
1099
  const maxResults = aiConfig.maxResults ?? 5;
1011
1100
  const query = lastUserMessage.content;
1012
- const scored = indexes.map((doc) => {
1013
- const q = query.toLowerCase();
1014
- const titleMatch = doc.title.toLowerCase().includes(q) ? 10 : 0;
1015
- const contentMatch = q.split(/\s+/).reduce((score, word) => {
1016
- return score + (doc.content.toLowerCase().includes(word) ? 1 : 0);
1017
- }, 0);
1018
- return {
1019
- ...doc,
1020
- score: titleMatch + contentMatch
1021
- };
1022
- }).filter((d) => d.score > 0).sort((a, b) => b.score - a.score).slice(0, maxResults);
1023
- const context = scored.map((doc) => `## ${doc.title}\nURL: ${doc.url}\n${doc.description ? `Description: ${doc.description}\n` : ""}\n${doc.content}`).join("\n\n---\n\n");
1024
- const systemPrompt = aiConfig.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
1025
- const llmMessages = [{
1101
+ await emitTrace({
1102
+ type: "user.input",
1103
+ name: "ask-ai",
1104
+ parentSpanId: runSpanId,
1105
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1106
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1107
+ durationMs: 0,
1108
+ status: "success",
1109
+ inputPreview: {
1110
+ messageCount: messages.length,
1111
+ questionLength: query.length,
1112
+ requestedModel: typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : void 0
1113
+ }
1114
+ });
1115
+ const retrievalStartedAt = Date.now();
1116
+ const retrievalStartedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1117
+ const retrievalSpanId = createDocsAgentTraceId("span");
1118
+ await emitTrace({
1119
+ type: "retrieval.query",
1120
+ name: "docs-index",
1121
+ spanId: retrievalSpanId,
1122
+ parentSpanId: runSpanId,
1123
+ startedAt: retrievalStartedAtIso,
1124
+ status: "started",
1125
+ inputPreview: {
1126
+ queryLength: query.length,
1127
+ maxResults,
1128
+ indexSize: indexes.length
1129
+ }
1130
+ });
1131
+ const retrieval = await buildDocsAskAIContext({
1132
+ pages: indexes,
1133
+ query,
1134
+ search,
1135
+ locale: analyticsContext.locale,
1136
+ pathname: url.searchParams.get("pathname") ?? void 0,
1137
+ siteTitle: "Documentation",
1138
+ baseUrl: url.origin,
1139
+ limit: maxResults
1140
+ });
1141
+ const scored = retrieval.results;
1142
+ await emitTrace({
1143
+ type: "retrieval.result",
1144
+ name: "docs-index",
1145
+ parentSpanId: retrievalSpanId,
1146
+ startedAt: retrievalStartedAtIso,
1147
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1148
+ durationMs: Math.max(0, Date.now() - retrievalStartedAt),
1149
+ status: "success",
1150
+ outputPreview: {
1151
+ resultCount: scored.length,
1152
+ urls: scored.slice(0, 5).map((doc) => doc.url)
1153
+ },
1154
+ metadata: {
1155
+ maxResults,
1156
+ indexSize: indexes.length
1157
+ }
1158
+ });
1159
+ const promptStartedAt = Date.now();
1160
+ const promptStartedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1161
+ const promptSpanId = createDocsAgentTraceId("span");
1162
+ const context = retrieval.context;
1163
+ const fullSystemPrompt = [aiConfig.systemPrompt ?? buildDefaultSystemPrompt(aiConfig), formatDocsAskAIPackageHints(retrieval.packageHints, aiConfig.packageName)].filter(Boolean).join("\n\n");
1164
+ const systemMessage = {
1026
1165
  role: "system",
1027
- content: context ? `${systemPrompt}\n\n---\n\nDocumentation context:\n\n${context}` : systemPrompt
1028
- }, ...messages.filter((m) => m.role !== "system")];
1166
+ content: context ? `${fullSystemPrompt}\n\n---\n\nDocumentation context:\n\n${context}` : fullSystemPrompt
1167
+ };
1168
+ const llmMessages = [systemMessage, ...messages.filter((m) => m.role !== "system")];
1169
+ await emitTrace({
1170
+ type: "prompt.build",
1171
+ name: "ask-ai.prompt",
1172
+ spanId: promptSpanId,
1173
+ parentSpanId: runSpanId,
1174
+ startedAt: promptStartedAtIso,
1175
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1176
+ durationMs: Math.max(0, Date.now() - promptStartedAt),
1177
+ status: "success",
1178
+ inputPreview: {
1179
+ messageCount: messages.length,
1180
+ retrievedCount: scored.length
1181
+ },
1182
+ outputPreview: {
1183
+ llmMessageCount: llmMessages.length,
1184
+ contextChars: context.length,
1185
+ systemMessageChars: systemMessage.content.length
1186
+ }
1187
+ });
1029
1188
  const resolved = resolveModelAndProvider(aiConfig, typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : void 0);
1030
1189
  if (!resolved.apiKey) {
1031
1190
  await emitDocsAnalyticsEvent(analytics, {
@@ -1044,6 +1203,13 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
1044
1203
  durationMs: Math.max(0, Date.now() - requestStartedAt)
1045
1204
  }
1046
1205
  });
1206
+ await emitRunError("missing_api_key", {
1207
+ status: 500,
1208
+ messageCount: messages.length,
1209
+ questionLength: query.length,
1210
+ retrievedCount: scored.length,
1211
+ model: resolved.model
1212
+ });
1047
1213
  return Response.json({ error: `AI is enabled but no API key was found. Either set apiKey in your docs.config ai section, configure a provider, or add OPENAI_API_KEY to your .env.local file.` }, { status: 500 });
1048
1214
  }
1049
1215
  await emitDocsAnalyticsEvent(analytics, {
@@ -1060,20 +1226,100 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
1060
1226
  model: resolved.model
1061
1227
  }
1062
1228
  });
1063
- const llmResponse = await fetch(`${resolved.baseUrl}/chat/completions`, {
1064
- method: "POST",
1065
- headers: {
1066
- "Content-Type": "application/json",
1067
- Authorization: `Bearer ${resolved.apiKey}`
1068
- },
1069
- body: JSON.stringify({
1070
- model: resolved.model,
1229
+ const modelStartedAt = Date.now();
1230
+ const modelStartedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1231
+ const modelSpanId = createDocsAgentTraceId("span");
1232
+ const providerOrigin = safeUrlOrigin(resolved.baseUrl);
1233
+ await emitTrace({
1234
+ type: "model.call",
1235
+ name: resolved.model,
1236
+ spanId: modelSpanId,
1237
+ parentSpanId: runSpanId,
1238
+ startedAt: modelStartedAtIso,
1239
+ status: "started",
1240
+ inputPreview: {
1241
+ messageCount: llmMessages.length,
1071
1242
  stream: true,
1072
- messages: llmMessages
1073
- })
1243
+ providerOrigin
1244
+ },
1245
+ metadata: { model: resolved.model }
1074
1246
  });
1247
+ let llmResponse;
1248
+ try {
1249
+ llmResponse = await fetch(`${resolved.baseUrl}/chat/completions`, {
1250
+ method: "POST",
1251
+ headers: {
1252
+ "Content-Type": "application/json",
1253
+ Authorization: `Bearer ${resolved.apiKey}`
1254
+ },
1255
+ body: JSON.stringify({
1256
+ model: resolved.model,
1257
+ stream: true,
1258
+ messages: llmMessages
1259
+ })
1260
+ });
1261
+ } catch (error) {
1262
+ const elapsed = Math.max(0, Date.now() - modelStartedAt);
1263
+ const message = error instanceof Error ? error.message : "Unknown error";
1264
+ await emitTrace({
1265
+ type: "model.error",
1266
+ name: resolved.model,
1267
+ parentSpanId: modelSpanId,
1268
+ startedAt: modelStartedAtIso,
1269
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1270
+ durationMs: elapsed,
1271
+ status: "error",
1272
+ outputPreview: { message },
1273
+ metadata: {
1274
+ model: resolved.model,
1275
+ providerOrigin
1276
+ }
1277
+ });
1278
+ await emitDocsAnalyticsEvent(analytics, {
1279
+ type: "api_ai_error",
1280
+ source: "server",
1281
+ url: request.url,
1282
+ path: url.pathname,
1283
+ locale: analyticsContext.locale,
1284
+ input: { question: query },
1285
+ properties: {
1286
+ reason: "llm_fetch_error",
1287
+ messageCount: messages.length,
1288
+ questionLength: query.length,
1289
+ retrievedCount: scored.length,
1290
+ model: resolved.model,
1291
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1292
+ }
1293
+ });
1294
+ await emitRunError("llm_fetch_error", {
1295
+ status: 502,
1296
+ messageCount: messages.length,
1297
+ questionLength: query.length,
1298
+ retrievedCount: scored.length,
1299
+ model: resolved.model
1300
+ });
1301
+ return Response.json({ error: "LLM API request failed." }, { status: 502 });
1302
+ }
1075
1303
  if (!llmResponse.ok) {
1076
1304
  const errText = await llmResponse.text().catch(() => "Unknown error");
1305
+ const elapsed = Math.max(0, Date.now() - modelStartedAt);
1306
+ await emitTrace({
1307
+ type: "model.error",
1308
+ name: resolved.model,
1309
+ parentSpanId: modelSpanId,
1310
+ startedAt: modelStartedAtIso,
1311
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1312
+ durationMs: elapsed,
1313
+ status: "error",
1314
+ outputPreview: {
1315
+ status: llmResponse.status,
1316
+ errorChars: errText.length
1317
+ },
1318
+ metadata: {
1319
+ model: resolved.model,
1320
+ providerOrigin
1321
+ }
1322
+ });
1077
1323
  await emitDocsAnalyticsEvent(analytics, {
1078
1324
  type: "api_ai_error",
1079
1325
  source: "server",
@@ -1091,6 +1337,14 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
1091
1337
  durationMs: Math.max(0, Date.now() - requestStartedAt)
1092
1338
  }
1093
1339
  });
1340
+ await emitRunError("llm_error", {
1341
+ status: 502,
1342
+ modelStatus: llmResponse.status,
1343
+ messageCount: messages.length,
1344
+ questionLength: query.length,
1345
+ retrievedCount: scored.length,
1346
+ model: resolved.model
1347
+ });
1094
1348
  return Response.json({ error: `LLM API error (${llmResponse.status}): ${errText}` }, { status: 502 });
1095
1349
  }
1096
1350
  await emitDocsAnalyticsEvent(analytics, {
@@ -1108,6 +1362,66 @@ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsConte
1108
1362
  durationMs: Math.max(0, Date.now() - requestStartedAt)
1109
1363
  }
1110
1364
  });
1365
+ const responseEndedAt = (/* @__PURE__ */ new Date()).toISOString();
1366
+ const modelDurationMs = Math.max(0, Date.now() - modelStartedAt);
1367
+ await emitTrace({
1368
+ type: "model.response",
1369
+ name: resolved.model,
1370
+ parentSpanId: modelSpanId,
1371
+ startedAt: modelStartedAtIso,
1372
+ endedAt: responseEndedAt,
1373
+ durationMs: modelDurationMs,
1374
+ status: "success",
1375
+ outputPreview: {
1376
+ status: llmResponse.status,
1377
+ stream: true,
1378
+ contentType: llmResponse.headers.get("content-type") ?? void 0
1379
+ },
1380
+ metadata: {
1381
+ model: resolved.model,
1382
+ providerOrigin
1383
+ }
1384
+ });
1385
+ await emitTrace({
1386
+ type: "model.stream",
1387
+ name: resolved.model,
1388
+ parentSpanId: modelSpanId,
1389
+ startedAt: modelStartedAtIso,
1390
+ endedAt: responseEndedAt,
1391
+ durationMs: modelDurationMs,
1392
+ status: "success",
1393
+ outputPreview: { stream: true },
1394
+ metadata: { model: resolved.model }
1395
+ });
1396
+ const runDurationMs = Math.max(0, Date.now() - requestStartedAt);
1397
+ await emitTrace({
1398
+ type: "agent.final",
1399
+ name: "ask-ai",
1400
+ parentSpanId: runSpanId,
1401
+ startedAt: trace.startedAt,
1402
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1403
+ durationMs: runDurationMs,
1404
+ status: "success",
1405
+ outputPreview: {
1406
+ stream: true,
1407
+ retrievedCount: scored.length
1408
+ },
1409
+ metadata: { model: resolved.model }
1410
+ });
1411
+ await emitTrace({
1412
+ type: "run.end",
1413
+ name: "ask-ai",
1414
+ spanId: runSpanId,
1415
+ startedAt: trace.startedAt,
1416
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1417
+ durationMs: runDurationMs,
1418
+ status: "success",
1419
+ outputPreview: {
1420
+ stream: true,
1421
+ retrievedCount: scored.length
1422
+ },
1423
+ metadata: { model: resolved.model }
1424
+ });
1111
1425
  return new Response(llmResponse.body, { headers: {
1112
1426
  "Content-Type": "text/event-stream",
1113
1427
  "Cache-Control": "no-cache",
@@ -1188,6 +1502,7 @@ function createDocsAPI(options) {
1188
1502
  const root = options?.rootDir ?? process.cwd();
1189
1503
  const entry = options?.entry ?? readEntry(root);
1190
1504
  const analytics = options?.analytics;
1505
+ const observability = options?.observability;
1191
1506
  const appDir = getNextAppDir(root);
1192
1507
  const contentDir = options?.contentDir ?? path.join(appDir, entry);
1193
1508
  const changelogConfig = resolveChangelogConfig(options?.changelog);
@@ -1586,7 +1901,7 @@ function createDocsAPI(options) {
1586
1901
  return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
1587
1902
  }
1588
1903
  const ctx = resolveContextFromRequest(request);
1589
- return handleAskAI(request, getIndexes(ctx), aiConfig, analytics, { locale: ctx.locale });
1904
+ return handleAskAI(request, getIndexes(ctx), aiConfig, resolveSearchRequestConfig(searchConfig, request.url), analytics, observability, { locale: ctx.locale });
1590
1905
  }
1591
1906
  };
1592
1907
  }
@@ -1616,6 +1931,7 @@ function createDocsMCPAPI(options = {}) {
1616
1931
  mcp: options.mcp ?? readMcpConfig(rootDir),
1617
1932
  search: options.search,
1618
1933
  analytics: options.analytics,
1934
+ observability: options.observability,
1619
1935
  defaultName: navTitle
1620
1936
  });
1621
1937
  return {
@@ -1,15 +1,21 @@
1
- import { CodeBlockCopyData, DocsAnalyticsConfig, DocsFeedbackData } from "@farming-labs/docs";
1
+ import { CodeBlockCopyData, DocsAnalyticsConfig, DocsAskAIActionData, DocsAskAIFeedbackData, DocsFeedbackData } from "@farming-labs/docs";
2
2
 
3
3
  //#region src/docs-client-hooks.d.ts
4
4
  type CopyHandler = (data: CodeBlockCopyData) => void;
5
5
  type FeedbackHandler = (data: DocsFeedbackData) => void | Promise<void>;
6
+ type AIActionHandler = (data: DocsAskAIActionData) => void | Promise<void>;
7
+ type AIFeedbackHandler = (data: DocsAskAIFeedbackData) => void | Promise<void>;
6
8
  declare function DocsClientHooks({
7
9
  onCopyClick,
8
10
  onFeedback,
11
+ onAIActions,
12
+ onAIFeedback,
9
13
  analytics
10
14
  }: {
11
15
  onCopyClick?: CopyHandler;
12
16
  onFeedback?: FeedbackHandler;
17
+ onAIActions?: AIActionHandler;
18
+ onAIFeedback?: AIFeedbackHandler;
13
19
  analytics?: boolean | DocsAnalyticsConfig;
14
20
  }): null;
15
21
  //#endregion
@@ -65,9 +65,11 @@ function useCodeCopyAnalytics(analytics) {
65
65
  return () => document.removeEventListener("click", handleClick, true);
66
66
  }, [analytics]);
67
67
  }
68
- function DocsClientHooks({ onCopyClick, onFeedback, analytics }) {
68
+ function DocsClientHooks({ onCopyClick, onFeedback, onAIActions, onAIFeedback, analytics }) {
69
69
  useWindowHook("__fdOnCopyClick__", onCopyClick);
70
70
  useWindowHook("__fdOnFeedback__", onFeedback);
71
+ useWindowHook("__fdOnAIActions__", onAIActions);
72
+ useWindowHook("__fdOnAIFeedback__", onAIFeedback);
71
73
  useAnalyticsHook(analytics);
72
74
  useCodeCopyAnalytics(analytics);
73
75
  return null;
@@ -622,6 +622,7 @@ function createDocsLayout(config, options) {
622
622
  const aiSuggestedQuestions = aiConfig?.suggestedQuestions;
623
623
  const aiLabel = aiConfig?.aiLabel;
624
624
  const aiLoaderVariant = aiConfig?.loader;
625
+ const aiFeedbackEnabled = aiConfig?.feedback === false || typeof aiConfig?.feedback === "object" && aiConfig.feedback.enabled === false ? false : true;
625
626
  const aiLoadingComponentHtml = typeof aiConfig?.loadingComponent === "function" ? serializeIcon(aiConfig.loadingComponent({ name: aiLabel || "AI" })) : void 0;
626
627
  const rawModelConfig = aiConfig?.model;
627
628
  let aiModels = aiConfig?.models;
@@ -707,7 +708,8 @@ function createDocsLayout(config, options) {
707
708
  loadingComponentHtml: aiLoadingComponentHtml,
708
709
  models: aiModels,
709
710
  defaultModelId: aiDefaultModelId,
710
- analytics: analyticsEnabled
711
+ analytics: analyticsEnabled,
712
+ feedbackEnabled: aiFeedbackEnabled
711
713
  })
712
714
  }),
713
715
  /* @__PURE__ */ jsx(Suspense, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/theme",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "Theme package for @farming-labs/docs — layout, provider, MDX components, and styles",
5
5
  "keywords": [
6
6
  "docs",
@@ -139,7 +139,7 @@
139
139
  "tsdown": "^0.20.3",
140
140
  "typescript": "^5.9.3",
141
141
  "vitest": "^3.2.4",
142
- "@farming-labs/docs": "0.1.71"
142
+ "@farming-labs/docs": "0.1.73"
143
143
  },
144
144
  "peerDependencies": {
145
145
  "@farming-labs/docs": ">=0.0.1",
package/styles/ai.css CHANGED
@@ -337,6 +337,48 @@
337
337
  animation: fd-ai-msg-in 300ms ease-out;
338
338
  }
339
339
 
340
+ .fd-ai-feedback {
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 4px;
344
+ margin-top: 10px;
345
+ padding-top: 8px;
346
+ border-top: 1px solid color-mix(in srgb, var(--color-fd-border, #1f1f2e) 70%, transparent);
347
+ }
348
+
349
+ .fd-ai-feedback-btn {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ justify-content: center;
353
+ width: 26px;
354
+ height: 26px;
355
+ border: 1px solid transparent;
356
+ border-radius: var(--radius, 6px);
357
+ background: transparent;
358
+ color: var(--color-fd-muted-foreground, #71717a);
359
+ cursor: pointer;
360
+ transition:
361
+ background 150ms,
362
+ border-color 150ms,
363
+ color 150ms;
364
+ }
365
+
366
+ .fd-ai-feedback-btn:hover {
367
+ color: var(--color-fd-foreground, #e4e4e7);
368
+ background: var(--color-fd-accent, rgba(255, 255, 255, 0.06));
369
+ }
370
+
371
+ .fd-ai-feedback-btn[data-active="true"] {
372
+ color: var(--color-fd-primary, #6366f1);
373
+ border-color: color-mix(in srgb, var(--color-fd-primary, #6366f1) 40%, transparent);
374
+ background: color-mix(in srgb, var(--color-fd-primary, #6366f1) 10%, transparent);
375
+ }
376
+
377
+ .fd-ai-feedback-btn[data-copied="true"] {
378
+ color: var(--color-fd-primary, #6366f1);
379
+ background: color-mix(in srgb, var(--color-fd-primary, #6366f1) 10%, transparent);
380
+ }
381
+
340
382
  @keyframes fd-ai-msg-in {
341
383
  from { opacity: 0; transform: translateY(6px); }
342
384
  to { opacity: 1; transform: translateY(0); }