@farming-labs/theme 0.1.144 → 0.2.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.
@@ -6,11 +6,17 @@ type AIModelOption = {
6
6
  id: string;
7
7
  label: string;
8
8
  };
9
+ type AIRequestMode = "openai-chat" | "docs-cloud";
10
+ type AIRequestHeaders = Record<string, string>;
11
+ type AIRequestStream = boolean;
9
12
  type LoaderVariant = "shimmer-dots" | "circular" | "dots" | "typing" | "wave" | "bars" | "pulse" | "pulse-dot" | "terminal" | "text-blink" | "text-shimmer" | "loading-dots";
10
13
  declare function DocsSearchDialog({
11
14
  open,
12
15
  onOpenChange,
13
16
  api,
17
+ requestMode,
18
+ requestHeaders,
19
+ requestStream,
14
20
  suggestedQuestions,
15
21
  aiLabel,
16
22
  loaderVariant,
@@ -23,6 +29,9 @@ declare function DocsSearchDialog({
23
29
  open: boolean;
24
30
  onOpenChange: (open: boolean) => void;
25
31
  api?: string;
32
+ requestMode?: AIRequestMode;
33
+ requestHeaders?: AIRequestHeaders;
34
+ requestStream?: AIRequestStream;
26
35
  suggestedQuestions?: string[];
27
36
  aiLabel?: string;
28
37
  loaderVariant?: LoaderVariant;
@@ -36,6 +45,9 @@ type FloatingPosition = "bottom-right" | "bottom-left" | "bottom-center";
36
45
  type FloatingStyle = "panel" | "modal" | "popover" | "full-modal";
37
46
  declare function FloatingAIChat({
38
47
  api,
48
+ requestMode,
49
+ requestHeaders,
50
+ requestStream,
39
51
  position,
40
52
  floatingStyle,
41
53
  triggerComponentHtml,
@@ -49,6 +61,9 @@ declare function FloatingAIChat({
49
61
  feedbackEnabled
50
62
  }: {
51
63
  api?: string;
64
+ requestMode?: AIRequestMode;
65
+ requestHeaders?: AIRequestHeaders;
66
+ requestStream?: AIRequestStream;
52
67
  position?: FloatingPosition;
53
68
  floatingStyle?: FloatingStyle;
54
69
  triggerComponentHtml?: string;
@@ -65,6 +80,9 @@ declare function AIModalDialog({
65
80
  open,
66
81
  onOpenChange,
67
82
  api,
83
+ requestMode,
84
+ requestHeaders,
85
+ requestStream,
68
86
  suggestedQuestions,
69
87
  aiLabel,
70
88
  loaderVariant,
@@ -77,6 +95,9 @@ declare function AIModalDialog({
77
95
  open: boolean;
78
96
  onOpenChange: (open: boolean) => void;
79
97
  api?: string;
98
+ requestMode?: AIRequestMode;
99
+ requestHeaders?: AIRequestHeaders;
100
+ requestStream?: AIRequestStream;
80
101
  suggestedQuestions?: string[];
81
102
  aiLabel?: string;
82
103
  loaderVariant?: LoaderVariant;
@@ -23,6 +23,158 @@ function createAIMessageId() {
23
23
  aiMessageId += 1;
24
24
  return `ai_${Date.now().toString(36)}_${aiMessageId.toString(36)}`;
25
25
  }
26
+ function isPlainObject(value) {
27
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
28
+ }
29
+ function buildAIRequestHeaders(requestHeaders, requestStream = true) {
30
+ return {
31
+ Accept: requestStream ? "text/event-stream, application/json" : "application/json",
32
+ "Content-Type": "application/json",
33
+ ...requestHeaders
34
+ };
35
+ }
36
+ function buildAIRequestBody(options) {
37
+ const messages = options.messages.map((message) => ({
38
+ role: message.role,
39
+ content: message.content
40
+ }));
41
+ if (options.requestMode === "docs-cloud") return JSON.stringify({
42
+ question: options.question,
43
+ messages,
44
+ answerMode: "auto",
45
+ answerStyle: "public",
46
+ modelPreference: options.model,
47
+ stream: options.requestStream !== false
48
+ });
49
+ return JSON.stringify({
50
+ messages,
51
+ model: options.model,
52
+ stream: options.requestStream !== false
53
+ });
54
+ }
55
+ function getOpenAICompatibleDeltaContent(value) {
56
+ if (!isPlainObject(value)) return void 0;
57
+ const firstChoice = Array.isArray(value.choices) ? value.choices[0] : void 0;
58
+ if (!isPlainObject(firstChoice)) return void 0;
59
+ const delta = firstChoice.delta;
60
+ if (!isPlainObject(delta)) return void 0;
61
+ return typeof delta.content === "string" ? delta.content : void 0;
62
+ }
63
+ function getDocsCloudAnswerContent(value) {
64
+ if (typeof value === "string") return value;
65
+ if (!isPlainObject(value)) return void 0;
66
+ for (const key of [
67
+ "answer",
68
+ "content",
69
+ "text",
70
+ "delta"
71
+ ]) {
72
+ const content = value[key];
73
+ if (typeof content === "string") return content;
74
+ }
75
+ }
76
+ async function readAIResponseContent(response, onContent) {
77
+ if ((response.headers.get("content-type")?.toLowerCase() ?? "").includes("application/json")) {
78
+ const answer = getDocsCloudAnswerContent(await response.json().catch(() => null)) ?? "";
79
+ if (answer) onContent(answer);
80
+ return answer;
81
+ }
82
+ if (!response.body) return "";
83
+ const reader = response.body.getReader();
84
+ const decoder = new TextDecoder();
85
+ let buffer = "";
86
+ let assistantContent = "";
87
+ while (true) {
88
+ const { done, value } = await reader.read();
89
+ if (done) break;
90
+ buffer += decoder.decode(value, { stream: true });
91
+ const lines = buffer.split("\n");
92
+ buffer = lines.pop() || "";
93
+ for (const line of lines) {
94
+ if (!line.startsWith("data: ")) continue;
95
+ const data = line.slice(6).trim();
96
+ if (!data || data === "[DONE]") continue;
97
+ try {
98
+ const json = JSON.parse(data);
99
+ const content = getOpenAICompatibleDeltaContent(json) ?? getDocsCloudAnswerContent(json);
100
+ if (content) {
101
+ assistantContent += content;
102
+ onContent(assistantContent);
103
+ }
104
+ } catch {}
105
+ }
106
+ }
107
+ return assistantContent;
108
+ }
109
+ function createSmoothAIStreamRenderer(onRender) {
110
+ let targetContent = "";
111
+ let visibleContent = "";
112
+ let frameId = null;
113
+ let lastFrameAt = 0;
114
+ let finishing = false;
115
+ let cancelled = false;
116
+ let resolveFinish;
117
+ const scheduleFrame = () => {
118
+ if (frameId !== null || cancelled) return;
119
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
120
+ frameId = window.requestAnimationFrame(renderFrame);
121
+ return;
122
+ }
123
+ frameId = setTimeout(() => renderFrame(Date.now()), 16);
124
+ };
125
+ const completeIfReady = () => {
126
+ if (!finishing || visibleContent !== targetContent || !resolveFinish) return;
127
+ const resolve = resolveFinish;
128
+ resolveFinish = void 0;
129
+ resolve(visibleContent);
130
+ };
131
+ const nextRevealCount = (gap, elapsedMs) => {
132
+ if (gap <= 0) return 0;
133
+ let charsPerSecond = 900;
134
+ if (gap > 2400) charsPerSecond = finishing ? 3600 : 3e3;
135
+ else if (gap > 1e3) charsPerSecond = finishing ? 2400 : 2e3;
136
+ else if (gap > 220) charsPerSecond = finishing ? 1600 : 1300;
137
+ return Math.max(1, Math.min(gap, Math.ceil(charsPerSecond * elapsedMs / 1e3)));
138
+ };
139
+ function renderFrame(timestamp) {
140
+ frameId = null;
141
+ if (cancelled) return;
142
+ const elapsedMs = lastFrameAt ? Math.min(48, Math.max(8, timestamp - lastFrameAt)) : 16;
143
+ lastFrameAt = timestamp;
144
+ const gap = targetContent.length - visibleContent.length;
145
+ if (gap > 0) {
146
+ const nextLength = visibleContent.length + nextRevealCount(gap, elapsedMs);
147
+ visibleContent = targetContent.slice(0, nextLength);
148
+ onRender(visibleContent);
149
+ }
150
+ if (visibleContent.length < targetContent.length) {
151
+ scheduleFrame();
152
+ return;
153
+ }
154
+ completeIfReady();
155
+ }
156
+ return {
157
+ push(content) {
158
+ if (cancelled) return;
159
+ targetContent = content;
160
+ scheduleFrame();
161
+ },
162
+ finish() {
163
+ finishing = true;
164
+ if (visibleContent === targetContent) return Promise.resolve(visibleContent);
165
+ return new Promise((resolve) => {
166
+ resolveFinish = resolve;
167
+ scheduleFrame();
168
+ });
169
+ },
170
+ cancel() {
171
+ cancelled = true;
172
+ if (frameId !== null) if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(frameId);
173
+ else clearTimeout(frameId);
174
+ frameId = null;
175
+ }
176
+ };
177
+ }
26
178
  function getLastUserQuestion(messages, assistantIndex) {
27
179
  for (let i = assistantIndex - 1; i >= 0; i -= 1) {
28
180
  const message = messages[i];
@@ -561,7 +713,7 @@ function AIFeedbackControls({ value, onCopy, onSelect }) {
561
713
  ]
562
714
  });
563
715
  }
564
- function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled = true, surface = "chat" }) {
716
+ function AIChat({ api, requestMode, requestHeaders, requestStream = true, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled = true, surface = "chat" }) {
565
717
  const label = aiLabel || "AI";
566
718
  const aiInputRef = useRef(null);
567
719
  const messagesEndRef = useRef(null);
@@ -571,8 +723,8 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
571
723
  });
572
724
  const effectiveModelId = selectedModel || (Array.isArray(models) && models.length > 0 ? models[0].id : void 0);
573
725
  useEffect(() => {
574
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
575
- }, [messages]);
726
+ messagesEndRef.current?.scrollIntoView({ behavior: isStreaming ? "auto" : "smooth" });
727
+ }, [isStreaming, messages]);
576
728
  useEffect(() => {
577
729
  aiInputRef.current?.focus();
578
730
  }, []);
@@ -602,15 +754,16 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
602
754
  model: effectiveModelId
603
755
  }
604
756
  });
757
+ let streamRenderer;
605
758
  try {
606
759
  const res = await fetch(api, {
607
760
  method: "POST",
608
- headers: { "Content-Type": "application/json" },
609
- body: JSON.stringify({
610
- messages: newMessages.map((m) => ({
611
- role: m.role,
612
- content: m.content
613
- })),
761
+ headers: buildAIRequestHeaders(requestHeaders, requestStream),
762
+ body: buildAIRequestBody({
763
+ requestMode,
764
+ requestStream,
765
+ question,
766
+ messages: newMessages,
614
767
  model: effectiveModelId
615
768
  })
616
769
  });
@@ -636,46 +789,32 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
636
789
  });
637
790
  return;
638
791
  }
639
- const reader = res.body.getReader();
640
- const decoder = new TextDecoder();
641
- let buffer = "";
642
- let assistantContent = "";
643
- while (true) {
644
- const { done, value } = await reader.read();
645
- if (done) break;
646
- buffer += decoder.decode(value, { stream: true });
647
- const lines = buffer.split("\n");
648
- buffer = lines.pop() || "";
649
- for (const line of lines) if (line.startsWith("data: ")) {
650
- const data = line.slice(6).trim();
651
- if (data === "[DONE]") continue;
652
- try {
653
- const content = JSON.parse(data).choices?.[0]?.delta?.content;
654
- if (content) {
655
- assistantContent += content;
656
- setMessages([...newMessages, {
657
- ...assistantMessage,
658
- content: assistantContent
659
- }]);
660
- }
661
- } catch {}
662
- }
663
- }
664
- if (assistantContent) setMessages([...newMessages, {
792
+ streamRenderer = createSmoothAIStreamRenderer((content) => {
793
+ setMessages([...newMessages, {
794
+ ...assistantMessage,
795
+ content
796
+ }]);
797
+ });
798
+ const assistantContent = await readAIResponseContent(res, (content) => {
799
+ streamRenderer?.push(content);
800
+ });
801
+ const finalContent = await streamRenderer.finish() || assistantContent;
802
+ if (finalContent) setMessages([...newMessages, {
665
803
  ...assistantMessage,
666
- content: assistantContent
804
+ content: finalContent
667
805
  }]);
668
806
  if (analytics) emitClientAnalyticsEvent({
669
807
  type: "ai_response",
670
808
  properties: {
671
809
  surface,
672
810
  questionLength: question.length,
673
- responseLength: assistantContent.length,
811
+ responseLength: finalContent.length,
674
812
  durationMs: Math.max(0, Date.now() - startedAt),
675
813
  model: effectiveModelId
676
814
  }
677
815
  });
678
816
  } catch {
817
+ streamRenderer?.cancel();
679
818
  setMessages([...newMessages, {
680
819
  ...assistantMessage,
681
820
  content: "Failed to connect. Please try again.",
@@ -694,6 +833,9 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
694
833
  }, [
695
834
  messages,
696
835
  api,
836
+ requestMode,
837
+ requestHeaders,
838
+ requestStream,
697
839
  isStreaming,
698
840
  setMessages,
699
841
  setAiInput,
@@ -860,7 +1002,7 @@ function AIChat({ api, messages, setMessages, aiInput, setAiInput, isStreaming,
860
1002
  })]
861
1003
  });
862
1004
  }
863
- function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1005
+ function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", requestMode, requestHeaders, requestStream, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
864
1006
  const [tab, setTab] = useState("search");
865
1007
  const [searchQuery, setSearchQuery] = useState("");
866
1008
  const [searchResults, setSearchResults] = useState([]);
@@ -1096,6 +1238,9 @@ function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQues
1096
1238
  }),
1097
1239
  tab === "ai" && /* @__PURE__ */ jsx(AIChat, {
1098
1240
  api,
1241
+ requestMode,
1242
+ requestHeaders,
1243
+ requestStream,
1099
1244
  messages,
1100
1245
  setMessages,
1101
1246
  aiInput,
@@ -1184,7 +1329,7 @@ function getContainerStyles(style, position) {
1184
1329
  function getAnimation(style) {
1185
1330
  return style === "modal" ? "fd-ai-float-center-in 200ms ease-out" : "fd-ai-float-in 200ms ease-out";
1186
1331
  }
1187
- function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1332
+ function FloatingAIChat({ api = "/api/docs", requestMode, requestHeaders, requestStream, position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1188
1333
  const [mounted, setMounted] = useState(false);
1189
1334
  const [isOpen, setIsOpen] = useState(false);
1190
1335
  const [messages, setMessages] = useState([]);
@@ -1224,6 +1369,9 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
1224
1369
  if (!mounted) return null;
1225
1370
  if (floatingStyle === "full-modal") return /* @__PURE__ */ jsx(FullModalAIChat, {
1226
1371
  api,
1372
+ requestMode,
1373
+ requestHeaders,
1374
+ requestStream,
1227
1375
  isOpen,
1228
1376
  setIsOpen,
1229
1377
  closeAI: closeFloatingAI,
@@ -1280,6 +1428,9 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
1280
1428
  ]
1281
1429
  }), /* @__PURE__ */ jsx(AIChat, {
1282
1430
  api,
1431
+ requestMode,
1432
+ requestHeaders,
1433
+ requestStream,
1283
1434
  messages,
1284
1435
  setMessages,
1285
1436
  aiInput,
@@ -1329,7 +1480,7 @@ function FloatingAIChat({ api = "/api/docs", position = "bottom-right", floating
1329
1480
  }))
1330
1481
  ] }), document.body);
1331
1482
  }
1332
- function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, triggerComponentHtml, position, models, defaultModelId, analytics, feedbackEnabled = true }) {
1483
+ function FullModalAIChat({ api, requestMode, requestHeaders, requestStream, isOpen, setIsOpen, closeAI, messages, setMessages, aiInput, setAiInput, isStreaming, setIsStreaming, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, triggerComponentHtml, position, models, defaultModelId, analytics, feedbackEnabled = true }) {
1333
1484
  const label = aiLabel || "AI";
1334
1485
  const inputRef = useRef(null);
1335
1486
  const listRef = useRef(null);
@@ -1345,9 +1496,9 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1345
1496
  useEffect(() => {
1346
1497
  if (listRef.current) listRef.current.scrollTo({
1347
1498
  top: listRef.current.scrollHeight,
1348
- behavior: "smooth"
1499
+ behavior: isStreaming ? "auto" : "smooth"
1349
1500
  });
1350
- }, [messages]);
1501
+ }, [isStreaming, messages]);
1351
1502
  const submitQuestion = useCallback(async (question) => {
1352
1503
  if (!question.trim() || isStreaming) return;
1353
1504
  const userMessage = {
@@ -1374,15 +1525,16 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1374
1525
  model: effectiveModelId
1375
1526
  }
1376
1527
  });
1528
+ let streamRenderer;
1377
1529
  try {
1378
1530
  const res = await fetch(api, {
1379
1531
  method: "POST",
1380
- headers: { "Content-Type": "application/json" },
1381
- body: JSON.stringify({
1382
- messages: newMessages.map((m) => ({
1383
- role: m.role,
1384
- content: m.content
1385
- })),
1532
+ headers: buildAIRequestHeaders(requestHeaders, requestStream),
1533
+ body: buildAIRequestBody({
1534
+ requestMode,
1535
+ requestStream,
1536
+ question,
1537
+ messages: newMessages,
1386
1538
  model: effectiveModelId
1387
1539
  })
1388
1540
  });
@@ -1408,46 +1560,32 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1408
1560
  });
1409
1561
  return;
1410
1562
  }
1411
- const reader = res.body.getReader();
1412
- const decoder = new TextDecoder();
1413
- let buffer = "";
1414
- let assistantContent = "";
1415
- while (true) {
1416
- const { done, value } = await reader.read();
1417
- if (done) break;
1418
- buffer += decoder.decode(value, { stream: true });
1419
- const lines = buffer.split("\n");
1420
- buffer = lines.pop() || "";
1421
- for (const line of lines) if (line.startsWith("data: ")) {
1422
- const data = line.slice(6).trim();
1423
- if (data === "[DONE]") continue;
1424
- try {
1425
- const content = JSON.parse(data).choices?.[0]?.delta?.content;
1426
- if (content) {
1427
- assistantContent += content;
1428
- setMessages([...newMessages, {
1429
- ...assistantMessage,
1430
- content: assistantContent
1431
- }]);
1432
- }
1433
- } catch {}
1434
- }
1435
- }
1436
- if (assistantContent) setMessages([...newMessages, {
1563
+ streamRenderer = createSmoothAIStreamRenderer((content) => {
1564
+ setMessages([...newMessages, {
1565
+ ...assistantMessage,
1566
+ content
1567
+ }]);
1568
+ });
1569
+ const assistantContent = await readAIResponseContent(res, (content) => {
1570
+ streamRenderer?.push(content);
1571
+ });
1572
+ const finalContent = await streamRenderer.finish() || assistantContent;
1573
+ if (finalContent) setMessages([...newMessages, {
1437
1574
  ...assistantMessage,
1438
- content: assistantContent
1575
+ content: finalContent
1439
1576
  }]);
1440
1577
  if (analytics) emitClientAnalyticsEvent({
1441
1578
  type: "ai_response",
1442
1579
  properties: {
1443
1580
  surface: "full-modal",
1444
1581
  questionLength: question.length,
1445
- responseLength: assistantContent.length,
1582
+ responseLength: finalContent.length,
1446
1583
  durationMs: Math.max(0, Date.now() - startedAt),
1447
1584
  model: effectiveModelId
1448
1585
  }
1449
1586
  });
1450
1587
  } catch {
1588
+ streamRenderer?.cancel();
1451
1589
  setMessages([...newMessages, {
1452
1590
  ...assistantMessage,
1453
1591
  content: "Failed to connect. Please try again.",
@@ -1466,6 +1604,9 @@ function FullModalAIChat({ api, isOpen, setIsOpen, closeAI, messages, setMessage
1466
1604
  }, [
1467
1605
  messages,
1468
1606
  api,
1607
+ requestMode,
1608
+ requestHeaders,
1609
+ requestStream,
1469
1610
  isStreaming,
1470
1611
  setMessages,
1471
1612
  setAiInput,
@@ -1677,7 +1818,7 @@ function TrashIcon() {
1677
1818
  ]
1678
1819
  });
1679
1820
  }
1680
- function AIModalDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1821
+ function AIModalDialog({ open, onOpenChange, api = "/api/docs", requestMode, requestHeaders, requestStream, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics = false, feedbackEnabled = true }) {
1681
1822
  const [messages, setMessages] = useState([]);
1682
1823
  const [aiInput, setAiInput] = useState("");
1683
1824
  const [isStreaming, setIsStreaming] = useState(false);
@@ -1746,6 +1887,9 @@ function AIModalDialog({ open, onOpenChange, api = "/api/docs", suggestedQuestio
1746
1887
  }),
1747
1888
  /* @__PURE__ */ jsx(AIChat, {
1748
1889
  api,
1890
+ requestMode,
1891
+ requestHeaders,
1892
+ requestStream,
1749
1893
  messages,
1750
1894
  setMessages,
1751
1895
  aiInput,
@@ -4,6 +4,9 @@ import * as react_jsx_runtime0 from "react/jsx-runtime";
4
4
  interface DocsAIFeaturesProps {
5
5
  mode: "search" | "floating" | "sidebar-icon";
6
6
  api?: string;
7
+ requestMode?: "openai-chat" | "docs-cloud";
8
+ requestHeaders?: Record<string, string>;
9
+ requestStream?: boolean;
7
10
  locale?: string;
8
11
  position?: "bottom-right" | "bottom-left" | "bottom-center";
9
12
  floatingStyle?: "panel" | "modal" | "popover" | "full-modal";
@@ -23,6 +26,9 @@ interface DocsAIFeaturesProps {
23
26
  declare function DocsAIFeatures({
24
27
  mode,
25
28
  api,
29
+ requestMode,
30
+ requestHeaders,
31
+ requestStream,
26
32
  locale,
27
33
  position,
28
34
  floatingStyle,
@@ -22,10 +22,13 @@ 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, feedbackEnabled = true }) {
25
+ function DocsAIFeatures({ mode, api = "/api/docs", requestMode, requestHeaders, requestStream, 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,
29
+ requestMode,
30
+ requestHeaders,
31
+ requestStream,
29
32
  suggestedQuestions,
30
33
  aiLabel,
31
34
  loaderVariant,
@@ -37,6 +40,9 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
37
40
  });
38
41
  if (mode === "sidebar-icon") return /* @__PURE__ */ jsx(SidebarIconModeAI, {
39
42
  api: localizedApi,
43
+ requestMode,
44
+ requestHeaders,
45
+ requestStream,
40
46
  suggestedQuestions,
41
47
  aiLabel,
42
48
  loaderVariant,
@@ -48,6 +54,9 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
48
54
  });
49
55
  return /* @__PURE__ */ jsx(FloatingAIChat, {
50
56
  api: localizedApi,
57
+ requestMode,
58
+ requestHeaders,
59
+ requestStream,
51
60
  position,
52
61
  floatingStyle,
53
62
  triggerComponentHtml,
@@ -61,7 +70,7 @@ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-ri
61
70
  feedbackEnabled
62
71
  });
63
72
  }
64
- function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
73
+ function SearchModeAI({ api, requestMode, requestHeaders, requestStream, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
65
74
  const [open, setOpen] = useState(false);
66
75
  useEffect(() => {
67
76
  function handler(e) {
@@ -108,6 +117,9 @@ function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loading
108
117
  open,
109
118
  onOpenChange: setOpen,
110
119
  api,
120
+ requestMode,
121
+ requestHeaders,
122
+ requestStream,
111
123
  suggestedQuestions,
112
124
  aiLabel,
113
125
  loaderVariant,
@@ -118,7 +130,7 @@ function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loading
118
130
  feedbackEnabled
119
131
  });
120
132
  }
121
- function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
133
+ function SidebarIconModeAI({ api, requestMode, requestHeaders, requestStream, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId, analytics, feedbackEnabled }) {
122
134
  const [searchOpen, setSearchOpen] = useState(false);
123
135
  const [aiOpen, setAiOpen] = useState(false);
124
136
  useEffect(() => {
@@ -165,6 +177,9 @@ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, lo
165
177
  open: searchOpen,
166
178
  onOpenChange: setSearchOpen,
167
179
  api,
180
+ requestMode,
181
+ requestHeaders,
182
+ requestStream,
168
183
  suggestedQuestions,
169
184
  aiLabel,
170
185
  loaderVariant,
@@ -177,6 +192,9 @@ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, lo
177
192
  open: aiOpen,
178
193
  onOpenChange: setAiOpen,
179
194
  api,
195
+ requestMode,
196
+ requestHeaders,
197
+ requestStream,
180
198
  suggestedQuestions,
181
199
  aiLabel,
182
200
  loaderVariant,
@@ -16,6 +16,7 @@ interface AIModelConfig {
16
16
  }
17
17
  interface AIOptions {
18
18
  enabled?: boolean;
19
+ provider?: string;
19
20
  model?: string | AIModelConfig;
20
21
  providers?: Record<string, AIProviderConfig>;
21
22
  systemPrompt?: string;
@@ -29,6 +30,7 @@ interface AIOptions {
29
30
  apiKey?: string;
30
31
  maxResults?: number;
31
32
  useMcp?: boolean | DocsAskAIMcpConfig;
33
+ stream?: boolean;
32
34
  }
33
35
  interface DocsAPIOptions {
34
36
  rootDir?: string;
@@ -44,6 +46,8 @@ interface DocsAPIOptions {
44
46
  language?: string;
45
47
  /** AI chat configuration */
46
48
  ai?: AIOptions;
49
+ /** Hosted Docs Cloud configuration. */
50
+ cloud?: DocsConfig["cloud"];
47
51
  /** i18n config (optional) */
48
52
  i18n?: DocsI18nConfig;
49
53
  /** Search configuration */
package/dist/docs-api.mjs CHANGED
@@ -35,6 +35,8 @@ const FILE_EXTS = [
35
35
  "js"
36
36
  ];
37
37
  const DEFAULT_DOCS_API_ROUTE = "/api/docs";
38
+ const DEFAULT_DOCS_CLOUD_API_BASE_URL = "https://docs-app.farming-labs.dev";
39
+ const DEFAULT_DOCS_CLOUD_API_KEY_ENV = "DOCS_CLOUD_API_KEY";
38
40
  const DEFAULT_AGENT_SPEC_ROUTE = "/api/docs/agent/spec";
39
41
  const DEFAULT_AGENT_SPEC_WELL_KNOWN_ROUTE = "/.well-known/agent";
40
42
  const DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE = "/.well-known/agent.json";
@@ -513,21 +515,25 @@ function readAIConfig(root) {
513
515
  if (!content.includes("ai:") && !content.includes("ai :")) return {};
514
516
  const enabledMatch = content.match(/ai\s*:\s*\{[^}]*enabled\s*:\s*(true|false)/s);
515
517
  if (enabledMatch && enabledMatch[1] === "false") return {};
518
+ const providerMatch = content.match(/ai\s*:\s*\{[^}]*provider\s*:\s*["']([^"']+)["']/s);
516
519
  const modelMatch = content.match(/ai\s*:\s*\{[^}]*model\s*:\s*["']([^"']+)["']/s);
517
520
  const baseUrlMatch = content.match(/ai\s*:\s*\{[^}]*baseUrl\s*:\s*["']([^"']+)["']/s);
518
521
  const apiKeyMatch = content.match(/ai\s*:\s*\{[^}]*apiKey\s*:\s*process\.env\.(\w+)/s);
519
522
  const maxResultsMatch = content.match(/ai\s*:\s*\{[^}]*maxResults\s*:\s*(\d+)/s);
520
523
  const useMcpMatch = content.match(/ai\s*:\s*\{[^}]*useMcp\s*:\s*(true|false)/s);
524
+ const streamMatch = content.match(/ai\s*:\s*\{[^}]*stream\s*:\s*(true|false)/s);
521
525
  const systemPromptMatch = content.match(/ai\s*:\s*\{[^}]*systemPrompt\s*:\s*["'`]([^"'`]+)["'`]/s);
522
526
  const packageNameMatch = content.match(/ai\s*:\s*\{[^}]*packageName\s*:\s*["'`]([^"'`]+)["'`]/s);
523
527
  const docsUrlMatch = content.match(/ai\s*:\s*\{[^}]*docsUrl\s*:\s*["'`]([^"'`]+)["'`]/s);
524
528
  return {
525
529
  enabled: true,
530
+ provider: providerMatch?.[1],
526
531
  model: modelMatch?.[1],
527
532
  baseUrl: baseUrlMatch?.[1],
528
533
  apiKey: apiKeyMatch?.[1] ? process.env[apiKeyMatch[1]] : void 0,
529
534
  maxResults: maxResultsMatch ? parseInt(maxResultsMatch[1], 10) : void 0,
530
535
  useMcp: useMcpMatch ? useMcpMatch[1] === "true" : void 0,
536
+ stream: streamMatch ? streamMatch[1] === "true" : void 0,
531
537
  systemPrompt: systemPromptMatch?.[1],
532
538
  packageName: packageNameMatch?.[1],
533
539
  docsUrl: docsUrlMatch?.[1]
@@ -536,6 +542,22 @@ function readAIConfig(root) {
536
542
  }
537
543
  return {};
538
544
  }
545
+ function readCloudConfig(root) {
546
+ for (const ext of FILE_EXTS) {
547
+ const configPath = path.join(root, `docs.config.${ext}`);
548
+ if (!fs.existsSync(configPath)) continue;
549
+ try {
550
+ const content = fs.readFileSync(configPath, "utf-8");
551
+ const sanitized = stripCommentsAndStrings(content);
552
+ const configObject = extractRootConfigObject(content, sanitized);
553
+ const cloudBlock = extractObjectLiteral(configObject?.content ?? content, configObject?.sanitized ?? sanitized, "cloud");
554
+ if (!cloudBlock) continue;
555
+ const apiKeyBlock = extractObjectLiteral(cloudBlock, stripCommentsAndStrings(cloudBlock), "apiKey");
556
+ const apiKeyEnv = apiKeyBlock ? readStringFromBlock(apiKeyBlock, "env") : void 0;
557
+ return apiKeyEnv ? { apiKey: { env: apiKeyEnv } } : {};
558
+ } catch {}
559
+ }
560
+ }
539
561
  function readMcpConfig(root) {
540
562
  for (const ext of FILE_EXTS) {
541
563
  const configPath = path.join(root, `docs.config.${ext}`);
@@ -1294,6 +1316,121 @@ function resolveModelAndProvider(aiConfig, requestedModelId) {
1294
1316
  apiKey
1295
1317
  };
1296
1318
  }
1319
+ function readRuntimeEnv(name) {
1320
+ const value = process.env[name]?.trim();
1321
+ return value ? value : void 0;
1322
+ }
1323
+ function resolveDocsCloudApiBaseUrl() {
1324
+ return (readRuntimeEnv("DOCS_CLOUD_API_URL") ?? readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_URL") ?? DEFAULT_DOCS_CLOUD_API_BASE_URL).replace(/\/+$/, "");
1325
+ }
1326
+ function resolveDocsCloudProjectId() {
1327
+ return readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID") ?? readRuntimeEnv("DOCS_CLOUD_PROJECT_ID");
1328
+ }
1329
+ function resolveDocsCloudApiKey(cloudConfig) {
1330
+ const envName = cloudConfig?.apiKey?.env?.trim() || DEFAULT_DOCS_CLOUD_API_KEY_ENV;
1331
+ return {
1332
+ envName,
1333
+ apiKey: readRuntimeEnv(envName)
1334
+ };
1335
+ }
1336
+ function createOpenAICompatibleSseChunk(content) {
1337
+ return `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`;
1338
+ }
1339
+ function createOpenAICompatibleSseResponse(content) {
1340
+ const encoder = new TextEncoder();
1341
+ const stream = new ReadableStream({ start(controller) {
1342
+ if (content) controller.enqueue(encoder.encode(createOpenAICompatibleSseChunk(content)));
1343
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
1344
+ controller.close();
1345
+ } });
1346
+ return new Response(stream, { headers: {
1347
+ "Content-Type": "text/event-stream",
1348
+ "Cache-Control": "no-cache",
1349
+ Connection: "keep-alive"
1350
+ } });
1351
+ }
1352
+ function getOpenAICompatibleDeltaContent(value) {
1353
+ if (!isPlainObject(value)) return void 0;
1354
+ const firstChoice = Array.isArray(value.choices) ? value.choices[0] : void 0;
1355
+ if (!isPlainObject(firstChoice)) return void 0;
1356
+ const delta = firstChoice.delta;
1357
+ if (!isPlainObject(delta)) return void 0;
1358
+ return typeof delta.content === "string" ? delta.content : void 0;
1359
+ }
1360
+ function getDocsCloudStreamContent(value) {
1361
+ if (typeof value === "string") return value;
1362
+ if (!isPlainObject(value)) return void 0;
1363
+ for (const key of [
1364
+ "content",
1365
+ "text",
1366
+ "answer",
1367
+ "delta"
1368
+ ]) {
1369
+ const content = value[key];
1370
+ if (typeof content === "string") return content;
1371
+ }
1372
+ }
1373
+ function createDocsCloudSseProxyResponse(body) {
1374
+ if (!body) return createOpenAICompatibleSseResponse("");
1375
+ const decoder = new TextDecoder();
1376
+ const encoder = new TextEncoder();
1377
+ const stream = new ReadableStream({ async start(controller) {
1378
+ const reader = body.getReader();
1379
+ let buffer = "";
1380
+ let doneSent = false;
1381
+ const enqueue = (chunk) => {
1382
+ controller.enqueue(encoder.encode(chunk));
1383
+ };
1384
+ const flushEvent = (event) => {
1385
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).filter(Boolean).join("\n").trim();
1386
+ if (!data) return;
1387
+ if (data === "[DONE]") {
1388
+ doneSent = true;
1389
+ enqueue("data: [DONE]\n\n");
1390
+ return;
1391
+ }
1392
+ try {
1393
+ const parsed = JSON.parse(data);
1394
+ if (getOpenAICompatibleDeltaContent(parsed) !== void 0) {
1395
+ enqueue(`data: ${data}\n\n`);
1396
+ return;
1397
+ }
1398
+ const content = getDocsCloudStreamContent(parsed);
1399
+ if (content) enqueue(createOpenAICompatibleSseChunk(content));
1400
+ } catch {
1401
+ enqueue(createOpenAICompatibleSseChunk(data));
1402
+ }
1403
+ };
1404
+ try {
1405
+ while (true) {
1406
+ const { value, done } = await reader.read();
1407
+ if (done) break;
1408
+ buffer += decoder.decode(value, { stream: true });
1409
+ const events = buffer.split(/\r?\n\r?\n/);
1410
+ buffer = events.pop() ?? "";
1411
+ for (const event of events) flushEvent(event);
1412
+ }
1413
+ buffer += decoder.decode();
1414
+ if (buffer.trim()) flushEvent(buffer);
1415
+ if (!doneSent) enqueue("data: [DONE]\n\n");
1416
+ controller.close();
1417
+ } catch (error) {
1418
+ controller.error(error);
1419
+ }
1420
+ } });
1421
+ return new Response(stream, { headers: {
1422
+ "Content-Type": "text/event-stream",
1423
+ "Cache-Control": "no-cache",
1424
+ Connection: "keep-alive"
1425
+ } });
1426
+ }
1427
+ function getDocsCloudAnswerFromPayload(payload) {
1428
+ if (!isPlainObject(payload)) return "";
1429
+ const answer = payload.answer;
1430
+ if (typeof answer === "string") return answer;
1431
+ const text = payload.text;
1432
+ return typeof text === "string" ? text : "";
1433
+ }
1297
1434
  function safeUrlOrigin(value) {
1298
1435
  try {
1299
1436
  return new URL(value).origin;
@@ -1305,7 +1442,7 @@ function getRequestAnalyticsProperties(request) {
1305
1442
  const userAgent = request.headers.get("user-agent")?.trim();
1306
1443
  return userAgent ? { userAgent } : {};
1307
1444
  }
1308
- async function handleAskAI(request, indexes, aiConfig, search, analytics, observability, analyticsContext = {}) {
1445
+ async function handleAskAI(request, indexes, aiConfig, cloudConfig, search, analytics, observability, analyticsContext = {}) {
1309
1446
  const url = new URL(request.url);
1310
1447
  const requestAnalyticsProperties = getRequestAnalyticsProperties(request);
1311
1448
  const requestStartedAt = Date.now();
@@ -1387,6 +1524,7 @@ async function handleAskAI(request, indexes, aiConfig, search, analytics, observ
1387
1524
  return Response.json({ error: "Invalid JSON body. Expected { messages: [...] }" }, { status: 400 });
1388
1525
  }
1389
1526
  const messages = body.messages;
1527
+ const shouldStreamResponse = body.stream !== false && (request.headers.get("accept")?.toLowerCase().includes("text/event-stream") ?? true);
1390
1528
  if (!Array.isArray(messages) || messages.length === 0) {
1391
1529
  await emitDocsAnalyticsEvent(analytics, {
1392
1530
  type: "api_ai_error",
@@ -1440,6 +1578,286 @@ async function handleAskAI(request, indexes, aiConfig, search, analytics, observ
1440
1578
  requestedModel: typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : void 0
1441
1579
  }
1442
1580
  });
1581
+ if (aiConfig.provider === "docs-cloud") {
1582
+ const requestedModel = typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : void 0;
1583
+ const model = requestedModel ?? "docs-cloud";
1584
+ const projectId = resolveDocsCloudProjectId();
1585
+ const { envName, apiKey } = resolveDocsCloudApiKey(cloudConfig);
1586
+ const cloudApiBaseUrl = resolveDocsCloudApiBaseUrl();
1587
+ const cloudAskUrl = projectId ? `${cloudApiBaseUrl}/v1/projects/${encodeURIComponent(projectId)}/knowledge/ask` : void 0;
1588
+ if (!projectId || !apiKey || !cloudAskUrl) {
1589
+ const reason = !projectId ? "missing_docs_cloud_project_id" : "missing_docs_cloud_api_key";
1590
+ await emitDocsAnalyticsEvent(analytics, {
1591
+ type: "api_ai_error",
1592
+ source: "server",
1593
+ url: request.url,
1594
+ path: url.pathname,
1595
+ locale: analyticsContext.locale,
1596
+ input: { question: query },
1597
+ properties: {
1598
+ ...requestAnalyticsProperties,
1599
+ reason,
1600
+ provider: "docs-cloud",
1601
+ messageCount: messages.length,
1602
+ questionLength: query.length,
1603
+ model,
1604
+ envName: !apiKey ? envName : void 0,
1605
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1606
+ }
1607
+ });
1608
+ await emitRunError(reason, {
1609
+ status: 500,
1610
+ provider: "docs-cloud",
1611
+ messageCount: messages.length,
1612
+ questionLength: query.length,
1613
+ model,
1614
+ envName: !apiKey ? envName : void 0
1615
+ });
1616
+ return Response.json({ error: !projectId ? "AI provider docs-cloud requires NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID or DOCS_CLOUD_PROJECT_ID." : `AI provider docs-cloud requires ${envName} to be set on the server.` }, { status: 500 });
1617
+ }
1618
+ await emitDocsAnalyticsEvent(analytics, {
1619
+ type: "api_ai_request",
1620
+ source: "server",
1621
+ url: request.url,
1622
+ path: url.pathname,
1623
+ locale: analyticsContext.locale,
1624
+ input: { question: query },
1625
+ properties: {
1626
+ ...requestAnalyticsProperties,
1627
+ provider: "docs-cloud",
1628
+ messageCount: messages.length,
1629
+ questionLength: query.length,
1630
+ model
1631
+ }
1632
+ });
1633
+ const modelStartedAt = Date.now();
1634
+ const modelStartedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1635
+ const modelSpanId = createDocsAgentTraceId("span");
1636
+ const providerOrigin = safeUrlOrigin(cloudApiBaseUrl);
1637
+ await emitTrace({
1638
+ type: "model.call",
1639
+ name: model,
1640
+ spanId: modelSpanId,
1641
+ parentSpanId: runSpanId,
1642
+ startedAt: modelStartedAtIso,
1643
+ status: "started",
1644
+ inputPreview: {
1645
+ messageCount: messages.length,
1646
+ stream: shouldStreamResponse,
1647
+ providerOrigin
1648
+ },
1649
+ metadata: {
1650
+ model,
1651
+ provider: "docs-cloud"
1652
+ }
1653
+ });
1654
+ let cloudResponse;
1655
+ try {
1656
+ cloudResponse = await fetch(cloudAskUrl, {
1657
+ method: "POST",
1658
+ headers: {
1659
+ Accept: shouldStreamResponse ? "text/event-stream, application/json" : "application/json",
1660
+ "Content-Type": "application/json",
1661
+ Authorization: `Bearer ${apiKey}`
1662
+ },
1663
+ body: JSON.stringify({
1664
+ question: query,
1665
+ answerMode: "auto",
1666
+ answerStyle: "public",
1667
+ modelPreference: requestedModel,
1668
+ stream: shouldStreamResponse
1669
+ })
1670
+ });
1671
+ } catch (error) {
1672
+ const elapsed = Math.max(0, Date.now() - modelStartedAt);
1673
+ const message = error instanceof Error ? error.message : "Unknown error";
1674
+ await emitTrace({
1675
+ type: "model.error",
1676
+ name: model,
1677
+ parentSpanId: modelSpanId,
1678
+ startedAt: modelStartedAtIso,
1679
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1680
+ durationMs: elapsed,
1681
+ status: "error",
1682
+ outputPreview: { message },
1683
+ metadata: {
1684
+ model,
1685
+ provider: "docs-cloud",
1686
+ providerOrigin
1687
+ }
1688
+ });
1689
+ await emitDocsAnalyticsEvent(analytics, {
1690
+ type: "api_ai_error",
1691
+ source: "server",
1692
+ url: request.url,
1693
+ path: url.pathname,
1694
+ locale: analyticsContext.locale,
1695
+ input: { question: query },
1696
+ properties: {
1697
+ ...requestAnalyticsProperties,
1698
+ reason: "docs_cloud_fetch_error",
1699
+ provider: "docs-cloud",
1700
+ messageCount: messages.length,
1701
+ questionLength: query.length,
1702
+ model,
1703
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1704
+ }
1705
+ });
1706
+ await emitRunError("docs_cloud_fetch_error", {
1707
+ status: 502,
1708
+ provider: "docs-cloud",
1709
+ messageCount: messages.length,
1710
+ questionLength: query.length,
1711
+ model
1712
+ });
1713
+ return Response.json({ error: "Docs Cloud Ask AI request failed." }, { status: 502 });
1714
+ }
1715
+ if (!cloudResponse.ok) {
1716
+ const errText = await cloudResponse.text().catch(() => "Unknown error");
1717
+ const elapsed = Math.max(0, Date.now() - modelStartedAt);
1718
+ await emitTrace({
1719
+ type: "model.error",
1720
+ name: model,
1721
+ parentSpanId: modelSpanId,
1722
+ startedAt: modelStartedAtIso,
1723
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1724
+ durationMs: elapsed,
1725
+ status: "error",
1726
+ outputPreview: {
1727
+ status: cloudResponse.status,
1728
+ errorChars: errText.length
1729
+ },
1730
+ metadata: {
1731
+ model,
1732
+ provider: "docs-cloud",
1733
+ providerOrigin
1734
+ }
1735
+ });
1736
+ await emitDocsAnalyticsEvent(analytics, {
1737
+ type: "api_ai_error",
1738
+ source: "server",
1739
+ url: request.url,
1740
+ path: url.pathname,
1741
+ locale: analyticsContext.locale,
1742
+ input: { question: query },
1743
+ properties: {
1744
+ ...requestAnalyticsProperties,
1745
+ reason: "docs_cloud_error",
1746
+ provider: "docs-cloud",
1747
+ status: cloudResponse.status,
1748
+ messageCount: messages.length,
1749
+ questionLength: query.length,
1750
+ model,
1751
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1752
+ }
1753
+ });
1754
+ await emitRunError("docs_cloud_error", {
1755
+ status: 502,
1756
+ modelStatus: cloudResponse.status,
1757
+ provider: "docs-cloud",
1758
+ messageCount: messages.length,
1759
+ questionLength: query.length,
1760
+ model
1761
+ });
1762
+ return Response.json({ error: `Docs Cloud Ask AI error (${cloudResponse.status}).` }, { status: 502 });
1763
+ }
1764
+ await emitDocsAnalyticsEvent(analytics, {
1765
+ type: "api_ai_response",
1766
+ source: "server",
1767
+ url: request.url,
1768
+ path: url.pathname,
1769
+ locale: analyticsContext.locale,
1770
+ input: { question: query },
1771
+ properties: {
1772
+ ...requestAnalyticsProperties,
1773
+ provider: "docs-cloud",
1774
+ messageCount: messages.length,
1775
+ questionLength: query.length,
1776
+ model,
1777
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1778
+ }
1779
+ });
1780
+ const responseEndedAt = (/* @__PURE__ */ new Date()).toISOString();
1781
+ const modelDurationMs = Math.max(0, Date.now() - modelStartedAt);
1782
+ const contentType = cloudResponse.headers.get("content-type") ?? void 0;
1783
+ await emitTrace({
1784
+ type: "model.response",
1785
+ name: model,
1786
+ parentSpanId: modelSpanId,
1787
+ startedAt: modelStartedAtIso,
1788
+ endedAt: responseEndedAt,
1789
+ durationMs: modelDurationMs,
1790
+ status: "success",
1791
+ outputPreview: {
1792
+ status: cloudResponse.status,
1793
+ stream: shouldStreamResponse,
1794
+ contentType
1795
+ },
1796
+ metadata: {
1797
+ model,
1798
+ provider: "docs-cloud",
1799
+ providerOrigin
1800
+ }
1801
+ });
1802
+ await emitTrace({
1803
+ type: "model.stream",
1804
+ name: model,
1805
+ parentSpanId: modelSpanId,
1806
+ startedAt: modelStartedAtIso,
1807
+ endedAt: responseEndedAt,
1808
+ durationMs: modelDurationMs,
1809
+ status: "success",
1810
+ outputPreview: { stream: shouldStreamResponse },
1811
+ metadata: {
1812
+ model,
1813
+ provider: "docs-cloud"
1814
+ }
1815
+ });
1816
+ const runDurationMs = Math.max(0, Date.now() - requestStartedAt);
1817
+ await emitTrace({
1818
+ type: "agent.final",
1819
+ name: "ask-ai",
1820
+ parentSpanId: runSpanId,
1821
+ startedAt: trace.startedAt,
1822
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1823
+ durationMs: runDurationMs,
1824
+ status: "success",
1825
+ outputPreview: {
1826
+ stream: shouldStreamResponse,
1827
+ provider: "docs-cloud"
1828
+ },
1829
+ metadata: {
1830
+ model,
1831
+ provider: "docs-cloud"
1832
+ }
1833
+ });
1834
+ await emitTrace({
1835
+ type: "run.end",
1836
+ name: "ask-ai",
1837
+ spanId: runSpanId,
1838
+ startedAt: trace.startedAt,
1839
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1840
+ durationMs: runDurationMs,
1841
+ status: "success",
1842
+ outputPreview: {
1843
+ stream: shouldStreamResponse,
1844
+ provider: "docs-cloud"
1845
+ },
1846
+ metadata: {
1847
+ model,
1848
+ provider: "docs-cloud"
1849
+ }
1850
+ });
1851
+ if (shouldStreamResponse && contentType?.includes("text/event-stream")) return createDocsCloudSseProxyResponse(cloudResponse.body);
1852
+ const cloudBody = await cloudResponse.text();
1853
+ let answer = cloudBody;
1854
+ let cloudPayload;
1855
+ try {
1856
+ cloudPayload = JSON.parse(cloudBody);
1857
+ answer = getDocsCloudAnswerFromPayload(cloudPayload);
1858
+ } catch {}
1859
+ return shouldStreamResponse ? createOpenAICompatibleSseResponse(answer) : Response.json(isPlainObject(cloudPayload) ? cloudPayload : { answer });
1860
+ }
1443
1861
  const retrievalStartedAt = Date.now();
1444
1862
  const retrievalStartedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1445
1863
  const retrievalSpanId = createDocsAgentTraceId("span");
@@ -1923,6 +2341,7 @@ function createDocsAPI(options) {
1923
2341
  const agentFeedbackConfig = resolveAgentFeedbackConfig(options?.feedback);
1924
2342
  const i18n = resolveDocsI18n(options?.i18n ?? readI18nConfig(root));
1925
2343
  const aiConfig = options?.ai ?? readAIConfig(root);
2344
+ const cloudConfig = options?.cloud ?? readCloudConfig(root);
1926
2345
  const searchConfig = options?.search;
1927
2346
  const llmsConfig = resolveLlmsTxtConfig(options?.llmsTxt, readLlmsTxtConfig(root));
1928
2347
  const sitemapConfig = options?.sitemap ?? readSitemapConfig(root);
@@ -2496,7 +2915,7 @@ function createDocsAPI(options) {
2496
2915
  return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
2497
2916
  }
2498
2917
  const ctx = resolveContextFromRequest(request);
2499
- return handleAskAI(request, getIndexes(ctx), aiConfig, resolveAskAISearchRequestConfig({
2918
+ return handleAskAI(request, getIndexes(ctx), aiConfig, cloudConfig, resolveAskAISearchRequestConfig({
2500
2919
  search: searchConfig,
2501
2920
  useMcp: aiConfig.useMcp,
2502
2921
  mcpEndpoint: mcpConfig.route,
@@ -0,0 +1,44 @@
1
+ //#region src/docs-cloud-ai-client.ts
2
+ const DEFAULT_DOCS_API_ROUTE = "/api/docs";
3
+ const DEFAULT_DOCS_CLOUD_API_BASE_URL = "https://docs-app.farming-labs.dev";
4
+ const DEFAULT_PUBLIC_DOCS_CLOUD_API_KEY_ENV = "NEXT_PUBLIC_DOCS_CLOUD_API_KEY";
5
+ function readRuntimeEnv(name) {
6
+ const value = process.env[name]?.trim();
7
+ return value ? value : void 0;
8
+ }
9
+ function resolveDocsCloudApiBaseUrl() {
10
+ return (readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_URL") ?? readRuntimeEnv("DOCS_CLOUD_API_URL") ?? DEFAULT_DOCS_CLOUD_API_BASE_URL).replace(/\/+$/, "");
11
+ }
12
+ function resolveDocsCloudProjectId() {
13
+ return readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID") ?? readRuntimeEnv("DOCS_CLOUD_PROJECT_ID");
14
+ }
15
+ function resolvePublicDocsCloudApiKey(config) {
16
+ const configuredEnv = config.cloud?.apiKey?.env?.trim();
17
+ if (!configuredEnv) return readRuntimeEnv(DEFAULT_PUBLIC_DOCS_CLOUD_API_KEY_ENV);
18
+ if (!configuredEnv.startsWith("NEXT_PUBLIC_")) return void 0;
19
+ return readRuntimeEnv(configuredEnv);
20
+ }
21
+ function resolveDocsCloudAIStream(config) {
22
+ const aiConfig = config.ai;
23
+ const stream = aiConfig?.stream ?? aiConfig?.streaming;
24
+ return typeof stream === "boolean" ? stream : true;
25
+ }
26
+ function resolveDocsCloudAIClientRequest(config, fallbackApi = DEFAULT_DOCS_API_ROUTE) {
27
+ if (config.ai?.provider !== "docs-cloud") return { api: fallbackApi };
28
+ const projectId = resolveDocsCloudProjectId();
29
+ const apiKey = resolvePublicDocsCloudApiKey(config);
30
+ const requestStream = resolveDocsCloudAIStream(config);
31
+ if (!projectId || !apiKey) return {
32
+ api: fallbackApi,
33
+ requestStream
34
+ };
35
+ return {
36
+ api: `${resolveDocsCloudApiBaseUrl()}/v1/projects/${encodeURIComponent(projectId)}/knowledge/ask`,
37
+ requestMode: "docs-cloud",
38
+ requestStream,
39
+ requestHeaders: { Authorization: `Bearer ${apiKey}` }
40
+ };
41
+ }
42
+
43
+ //#endregion
44
+ export { resolveDocsCloudAIClientRequest };
@@ -2,6 +2,7 @@ import { serializeIcon } from "./serialize-icon.mjs";
2
2
  import { withLangInUrl } from "./i18n.mjs";
3
3
  import { DocsPageClient } from "./docs-page-client.mjs";
4
4
  import { DocsAIFeatures } from "./docs-ai-features.mjs";
5
+ import { resolveDocsCloudAIClientRequest } from "./docs-cloud-ai-client.mjs";
5
6
  import { DocsCommandSearch } from "./docs-command-search.mjs";
6
7
  import { resolveOpenDocsProviders } from "./open-docs-providers.mjs";
7
8
  import { resolvePageReadingTime, resolveReadingTimeOptions } from "./reading-time.mjs";
@@ -632,6 +633,7 @@ function createDocsLayout(config, options) {
632
633
  const i18n = resolveDocsI18nConfig(getDocsI18n(config));
633
634
  const activeLocale = localeContext.locale ?? i18n?.defaultLocale;
634
635
  const docsApiUrl = withLangInUrl("/api/docs", activeLocale);
636
+ const aiClientRequest = resolveDocsCloudAIClientRequest(config, docsApiUrl);
635
637
  const changelogConfig = resolveChangelogConfig(config.changelog);
636
638
  const changelogBasePath = changelogConfig.enabled ? publicDocsRoute(localeContext, [changelogConfig.path]) : void 0;
637
639
  const navTitle = config.nav?.title ?? "Docs";
@@ -758,7 +760,10 @@ function createDocsLayout(config, options) {
758
760
  fallback: null,
759
761
  children: /* @__PURE__ */ jsx(DocsAIFeatures, {
760
762
  mode: aiMode,
761
- api: docsApiUrl,
763
+ api: aiClientRequest.api,
764
+ requestMode: aiClientRequest.requestMode,
765
+ requestHeaders: aiClientRequest.requestHeaders,
766
+ requestStream: aiClientRequest.requestStream,
762
767
  locale: activeLocale,
763
768
  position: aiPosition,
764
769
  floatingStyle: aiFloatingStyle,
@@ -2,6 +2,7 @@ import { withLangInUrl } from "./i18n.mjs";
2
2
  import { escapeJsonLdForScript } from "./json-ld.mjs";
3
3
  import { DocsPageClient } from "./docs-page-client.mjs";
4
4
  import { DocsAIFeatures } from "./docs-ai-features.mjs";
5
+ import { resolveDocsCloudAIClientRequest } from "./docs-cloud-ai-client.mjs";
5
6
  import { DocsCommandSearch } from "./docs-command-search.mjs";
6
7
  import { resolveOpenDocsProviders } from "./open-docs-providers.mjs";
7
8
  import { resolveReadingTimeOptions } from "./reading-time.mjs";
@@ -220,6 +221,7 @@ function TanstackDocsLayout({ config, tree, locale, description, readingTime, la
220
221
  const tocStyle = tocConfig?.style;
221
222
  const analyticsEnabled = resolveDocsAnalyticsConfig(config.analytics).enabled;
222
223
  const docsApiUrl = withLangInUrl("/api/docs", locale);
224
+ const aiClientRequest = resolveDocsCloudAIClientRequest(config, docsApiUrl);
223
225
  const navTitle = config.nav?.title ?? "Docs";
224
226
  const navUrl = withLangInUrl(config.nav?.url ?? `/${config.entry ?? "docs"}`, locale);
225
227
  const themeSwitch = resolveThemeSwitch(config.themeToggle);
@@ -327,7 +329,10 @@ function TanstackDocsLayout({ config, tree, locale, description, readingTime, la
327
329
  fallback: null,
328
330
  children: /* @__PURE__ */ jsx(DocsAIFeatures, {
329
331
  mode: aiMode,
330
- api: docsApiUrl,
332
+ api: aiClientRequest.api,
333
+ requestMode: aiClientRequest.requestMode,
334
+ requestHeaders: aiClientRequest.requestHeaders,
335
+ requestStream: aiClientRequest.requestStream,
331
336
  locale,
332
337
  position: aiPosition,
333
338
  floatingStyle: aiFloatingStyle,
@@ -140,7 +140,7 @@ const threadlinePageActions = {
140
140
  name: "T3 Chat",
141
141
  urlTemplate: "https://t3.chat/new?q={prompt}",
142
142
  promptUrlTemplate: "https://t3.chat/new?q={prompt}",
143
- icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4.5 5.5h15v9.75h-7.2L8 19.25v-4H4.5V5.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/><path d="M8 9h8M8 12h5.5" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"/></svg>`
143
+ icon: `<svg width="16" height="16" viewBox="0 0 512 512" fill="none" aria-hidden="true"><path fill="var(--color-fd-foreground, currentColor)" d="M115.3 407.6c-4.7 2.7-10.4-1.1-9.7-6.5l11.7-87.8C100.9 292.3 92 267.9 92 242c0-80.7 83.7-146.2 187-146.2S466 161.3 466 242 382.3 388.2 279 388.2c-28.2 0-55-4.9-78.9-13.6l-84.8 33Z"/></svg>`
144
144
  }
145
145
  ]
146
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/theme",
3
- "version": "0.1.144",
3
+ "version": "0.2.0",
4
4
  "description": "Theme package for @farming-labs/docs — layout, provider, MDX components, and styles",
5
5
  "keywords": [
6
6
  "docs",
@@ -145,7 +145,7 @@
145
145
  "tsdown": "^0.20.3",
146
146
  "typescript": "^5.9.3",
147
147
  "vitest": "^4.1.8",
148
- "@farming-labs/docs": "0.1.144"
148
+ "@farming-labs/docs": "0.2.0"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@farming-labs/docs": ">=0.0.1",
package/styles/ai.css CHANGED
@@ -777,20 +777,32 @@
777
777
 
778
778
  /* ─── Streaming cursor ───────────────────────────────────────────── */
779
779
 
780
+ .fd-ai-streaming {
781
+ text-rendering: optimizeLegibility;
782
+ }
783
+
780
784
  .fd-ai-streaming::after {
781
785
  content: "";
782
786
  display: inline-block;
783
787
  width: 2px;
784
- height: 1em;
788
+ height: 0.95em;
785
789
  background: var(--color-fd-primary, #6366f1);
786
- margin-left: 2px;
790
+ border-radius: 999px;
791
+ margin-left: 3px;
787
792
  vertical-align: text-bottom;
788
- animation: fd-ai-cursor-blink 0.8s step-end infinite;
793
+ animation: fd-ai-cursor-blink 900ms ease-in-out infinite;
794
+ box-shadow: 0 0 10px color-mix(in srgb, var(--color-fd-primary, #6366f1) 35%, transparent);
789
795
  }
790
796
 
791
797
  @keyframes fd-ai-cursor-blink {
792
798
  0%, 100% { opacity: 1; }
793
- 50% { opacity: 0; }
799
+ 50% { opacity: 0.25; }
800
+ }
801
+
802
+ @media (prefers-reduced-motion: reduce) {
803
+ .fd-ai-streaming::after {
804
+ animation: none;
805
+ }
794
806
  }
795
807
 
796
808
  /* ─── Floating trigger button ────────────────────────────────────── */