jamdesk 1.0.13 → 1.0.14

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.
Files changed (132) hide show
  1. package/README.md +89 -4
  2. package/dist/__tests__/unit/auth.test.d.ts +2 -0
  3. package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/unit/auth.test.js +169 -0
  5. package/dist/__tests__/unit/auth.test.js.map +1 -0
  6. package/dist/__tests__/unit/config.test.d.ts +2 -0
  7. package/dist/__tests__/unit/config.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/config.test.js +76 -0
  9. package/dist/__tests__/unit/config.test.js.map +1 -0
  10. package/dist/__tests__/unit/deploy.test.d.ts +2 -0
  11. package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
  12. package/dist/__tests__/unit/deploy.test.js +273 -0
  13. package/dist/__tests__/unit/deploy.test.js.map +1 -0
  14. package/dist/__tests__/unit/deps-sync.test.js +3 -1
  15. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
  17. package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
  19. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
  20. package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
  21. package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
  23. package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
  24. package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
  25. package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/loading-page.test.js +73 -0
  27. package/dist/__tests__/unit/loading-page.test.js.map +1 -0
  28. package/dist/__tests__/unit/login.test.d.ts +2 -0
  29. package/dist/__tests__/unit/login.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/login.test.js +100 -0
  31. package/dist/__tests__/unit/login.test.js.map +1 -0
  32. package/dist/__tests__/unit/logout.test.d.ts +2 -0
  33. package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/logout.test.js +39 -0
  35. package/dist/__tests__/unit/logout.test.js.map +1 -0
  36. package/dist/__tests__/unit/tarball.test.d.ts +2 -0
  37. package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/tarball.test.js +126 -0
  39. package/dist/__tests__/unit/tarball.test.js.map +1 -0
  40. package/dist/__tests__/unit/whoami.test.d.ts +2 -0
  41. package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
  42. package/dist/__tests__/unit/whoami.test.js +47 -0
  43. package/dist/__tests__/unit/whoami.test.js.map +1 -0
  44. package/dist/commands/deploy.d.ts +13 -0
  45. package/dist/commands/deploy.d.ts.map +1 -0
  46. package/dist/commands/deploy.js +265 -0
  47. package/dist/commands/deploy.js.map +1 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +48 -25
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/login.d.ts +8 -0
  52. package/dist/commands/login.d.ts.map +1 -0
  53. package/dist/commands/login.js +135 -0
  54. package/dist/commands/login.js.map +1 -0
  55. package/dist/commands/logout.d.ts +5 -0
  56. package/dist/commands/logout.d.ts.map +1 -0
  57. package/dist/commands/logout.js +17 -0
  58. package/dist/commands/logout.js.map +1 -0
  59. package/dist/commands/whoami.d.ts +5 -0
  60. package/dist/commands/whoami.d.ts.map +1 -0
  61. package/dist/commands/whoami.js +24 -0
  62. package/dist/commands/whoami.js.map +1 -0
  63. package/dist/index.js +50 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/lib/auth.d.ts +34 -0
  66. package/dist/lib/auth.d.ts.map +1 -0
  67. package/dist/lib/auth.js +105 -0
  68. package/dist/lib/auth.js.map +1 -0
  69. package/dist/lib/config.d.ts +9 -0
  70. package/dist/lib/config.d.ts.map +1 -1
  71. package/dist/lib/config.js +7 -1
  72. package/dist/lib/config.js.map +1 -1
  73. package/dist/lib/dev-loading-server.d.ts +22 -0
  74. package/dist/lib/dev-loading-server.d.ts.map +1 -0
  75. package/dist/lib/dev-loading-server.js +117 -0
  76. package/dist/lib/dev-loading-server.js.map +1 -0
  77. package/dist/lib/docs-config.d.ts +1 -0
  78. package/dist/lib/docs-config.d.ts.map +1 -1
  79. package/dist/lib/docs-config.js.map +1 -1
  80. package/dist/lib/docs-json-writer.d.ts +2 -0
  81. package/dist/lib/docs-json-writer.d.ts.map +1 -0
  82. package/dist/lib/docs-json-writer.js +35 -0
  83. package/dist/lib/docs-json-writer.js.map +1 -0
  84. package/dist/lib/loading-page.d.ts +11 -0
  85. package/dist/lib/loading-page.d.ts.map +1 -0
  86. package/dist/lib/loading-page.js +222 -0
  87. package/dist/lib/loading-page.js.map +1 -0
  88. package/dist/lib/output.d.ts +13 -5
  89. package/dist/lib/output.d.ts.map +1 -1
  90. package/dist/lib/output.js +22 -5
  91. package/dist/lib/output.js.map +1 -1
  92. package/dist/lib/tarball.d.ts +28 -0
  93. package/dist/lib/tarball.d.ts.map +1 -0
  94. package/dist/lib/tarball.js +117 -0
  95. package/dist/lib/tarball.js.map +1 -0
  96. package/package.json +5 -2
  97. package/vendored/app/[[...slug]]/page.tsx +6 -20
  98. package/vendored/app/api/chat/[project]/route.ts +323 -0
  99. package/vendored/app/api/mcp/[project]/route.ts +2 -63
  100. package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
  101. package/vendored/components/chat/ChatEmptyState.tsx +79 -0
  102. package/vendored/components/chat/ChatFAB.tsx +36 -0
  103. package/vendored/components/chat/ChatInput.tsx +106 -0
  104. package/vendored/components/chat/ChatMessage.tsx +176 -0
  105. package/vendored/components/chat/ChatPanel.tsx +206 -0
  106. package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
  107. package/vendored/components/chat/LazyChatPanel.tsx +19 -0
  108. package/vendored/components/layout/LayoutWrapper.tsx +134 -44
  109. package/vendored/components/layout/PageColumns.tsx +40 -0
  110. package/vendored/components/navigation/Header.tsx +74 -29
  111. package/vendored/components/navigation/Sidebar.tsx +17 -2
  112. package/vendored/hooks/useChat.ts +335 -0
  113. package/vendored/hooks/useChatPanel.tsx +101 -0
  114. package/vendored/lib/anthropic-client.ts +19 -0
  115. package/vendored/lib/build/extract-tarball.ts +150 -0
  116. package/vendored/lib/chat-prompt.ts +56 -0
  117. package/vendored/lib/docs-types.ts +14 -0
  118. package/vendored/lib/docs.ts +22 -4
  119. package/vendored/lib/embedding-chunker.ts +173 -0
  120. package/vendored/lib/generate-starter-questions.ts +98 -0
  121. package/vendored/lib/isr-build-executor.ts +2 -1
  122. package/vendored/lib/middleware-helpers.ts +21 -0
  123. package/vendored/lib/route-helpers.ts +96 -0
  124. package/vendored/lib/snippet-loader-isr.ts +107 -1
  125. package/vendored/lib/static-artifacts.ts +3 -2
  126. package/vendored/lib/validate-config.ts +1 -0
  127. package/vendored/lib/vector-store.ts +213 -0
  128. package/vendored/schema/docs-schema.json +33 -0
  129. package/vendored/scripts/dev-project.cjs +6 -0
  130. package/vendored/shared/types.ts +6 -5
  131. package/vendored/tailwind.config.ts +9 -0
  132. package/vendored/themes/jam/variables.css +2 -2
@@ -0,0 +1,335 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
+
5
+ export interface Citation {
6
+ title: string;
7
+ slug: string;
8
+ section?: string;
9
+ }
10
+
11
+ export interface ChatMessage {
12
+ id: string;
13
+ role: 'user' | 'assistant';
14
+ content: string;
15
+ citations?: Citation[];
16
+ clarificationOptions?: string[];
17
+ /** Set to true after user taps a clarification option — disables buttons on this message. */
18
+ clarificationSelected?: boolean;
19
+ isStreaming?: boolean;
20
+ }
21
+
22
+ function generateId(): string {
23
+ return typeof crypto !== 'undefined' && crypto.randomUUID
24
+ ? crypto.randomUUID()
25
+ : `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
26
+ }
27
+
28
+ const STORAGE_KEY = 'jd-chat-messages';
29
+
30
+ /** Save messages to sessionStorage (best-effort, silently fails during SSR). */
31
+ function saveMessages(messages: ChatMessage[]): void {
32
+ try {
33
+ if (messages.length === 0) {
34
+ sessionStorage.removeItem(STORAGE_KEY);
35
+ } else {
36
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
37
+ }
38
+ } catch {}
39
+ }
40
+
41
+ /** Load messages from sessionStorage, clearing any stale streaming flags. */
42
+ function loadMessages(): ChatMessage[] {
43
+ try {
44
+ const saved = sessionStorage.getItem(STORAGE_KEY);
45
+ if (!saved) return [];
46
+ const parsed: ChatMessage[] = JSON.parse(saved);
47
+ return parsed.map(m => ({ ...m, isStreaming: false }));
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Hook for managing chat state and streaming SSE responses.
55
+ * POSTs to the given endpoint — in production, /_chat is rewritten by middleware
56
+ * to /api/chat/{projectSlug}. In local dev, the direct API path is used.
57
+ *
58
+ * Messages persist in sessionStorage across page navigations within the same tab.
59
+ */
60
+ export function useChat(endpoint = '/_chat'): {
61
+ messages: ChatMessage[];
62
+ sendMessage: (text: string) => Promise<void>;
63
+ isLoading: boolean;
64
+ abort: () => void;
65
+ retry: () => void;
66
+ clearChat: () => void;
67
+ error: string | null;
68
+ markClarificationSelected: (messageId: string) => void;
69
+ } {
70
+ const [messages, setMessages] = useState<ChatMessage[]>(loadMessages);
71
+ const [isLoading, setIsLoading] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const abortControllerRef = useRef<AbortController | null>(null);
74
+ const isLoadingRef = useRef(false);
75
+ const lastUserMessageRef = useRef<string | null>(null);
76
+ const messagesRef = useRef<ChatMessage[]>(messages);
77
+
78
+ // Keep messagesRef in sync for use in callbacks without stale closures
79
+ useEffect(() => {
80
+ messagesRef.current = messages;
81
+ }, [messages]);
82
+
83
+ // Persist messages to sessionStorage — debounced to avoid thrashing during streaming
84
+ useEffect(() => {
85
+ const timer = setTimeout(() => saveMessages(messages), 500);
86
+ return () => clearTimeout(timer);
87
+ }, [messages]);
88
+
89
+ // Restore lastUserMessageRef from persisted messages on mount
90
+ useEffect(() => {
91
+ const lastUser = messagesRef.current.filter(m => m.role === 'user').at(-1);
92
+ if (lastUser) lastUserMessageRef.current = lastUser.content;
93
+ // eslint-disable-next-line react-hooks/exhaustive-deps
94
+ }, []);
95
+
96
+ /** Mark an assistant message as done streaming and reset loading state.
97
+ * If the assistant message has no content, remove it to avoid an empty bubble. */
98
+ const finishLoading = useCallback((assistantId: string): void => {
99
+ setMessages((prev) => {
100
+ const msg = prev.find((m) => m.id === assistantId);
101
+ if (msg && !msg.content) {
102
+ return prev.filter((m) => m.id !== assistantId);
103
+ }
104
+ return prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m));
105
+ });
106
+ setIsLoading(false);
107
+ isLoadingRef.current = false;
108
+ abortControllerRef.current = null;
109
+ }, []);
110
+
111
+ const processStream = useCallback(
112
+ async (response: Response, assistantId: string) => {
113
+ const reader = response.body!.getReader();
114
+ const decoder = new TextDecoder();
115
+ let buffer = '';
116
+
117
+ // Batch text chunks — accumulate in ref, flush to state once per animation frame.
118
+ // This reduces React re-renders from ~100/sec (per SSE chunk) to ~60/sec (per frame).
119
+ let pendingText = '';
120
+ let rafId: ReturnType<typeof requestAnimationFrame> | null = null;
121
+
122
+ const flushText = () => {
123
+ if (!pendingText) return;
124
+ const text = pendingText;
125
+ pendingText = '';
126
+ rafId = null;
127
+ setMessages((prev) =>
128
+ prev.map((msg) =>
129
+ msg.id === assistantId
130
+ ? { ...msg, content: msg.content + text }
131
+ : msg,
132
+ ),
133
+ );
134
+ };
135
+
136
+ try {
137
+ while (true) {
138
+ const { done, value } = await reader.read();
139
+ if (done) break;
140
+
141
+ buffer += decoder.decode(value, { stream: true });
142
+ const lines = buffer.split('\n');
143
+ buffer = lines.pop() || '';
144
+
145
+ for (const line of lines) {
146
+ const trimmed = line.trim();
147
+ if (!trimmed.startsWith('data: ')) continue;
148
+
149
+ let event: { type: string; content?: string; message?: string; sources?: Citation[]; options?: string[] };
150
+ try {
151
+ event = JSON.parse(trimmed.slice(6));
152
+ } catch {
153
+ continue;
154
+ }
155
+
156
+ switch (event.type) {
157
+ case 'text':
158
+ pendingText += event.content || '';
159
+ if (rafId === null) {
160
+ rafId = requestAnimationFrame(flushText);
161
+ }
162
+ break;
163
+
164
+ case 'citations':
165
+ setMessages((prev) =>
166
+ prev.map((msg) =>
167
+ msg.id === assistantId ? { ...msg, citations: event.sources } : msg,
168
+ ),
169
+ );
170
+ break;
171
+
172
+ case 'clarification':
173
+ setMessages((prev) =>
174
+ prev.map((msg) =>
175
+ msg.id === assistantId
176
+ ? { ...msg, clarificationOptions: event.options as string[] }
177
+ : msg,
178
+ ),
179
+ );
180
+ break;
181
+
182
+ case 'done':
183
+ // finishLoading in `finally` handles cleanup
184
+ break;
185
+
186
+ case 'error':
187
+ setError(event.message || 'An error occurred');
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ } catch (err) {
193
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
194
+ setError('The response was interrupted. Please try again.');
195
+ }
196
+ } finally {
197
+ // Flush any remaining batched text before finishing
198
+ if (rafId !== null) cancelAnimationFrame(rafId);
199
+ flushText();
200
+ // Unified cleanup: removes empty bubbles, marks streaming done, resets loading
201
+ finishLoading(assistantId);
202
+ }
203
+ },
204
+ [finishLoading],
205
+ );
206
+
207
+ const sendMessage = useCallback(
208
+ async (text: string) => {
209
+ const trimmed = text.trim();
210
+ if (!trimmed || isLoadingRef.current) return;
211
+
212
+ setError(null);
213
+ setIsLoading(true);
214
+ isLoadingRef.current = true;
215
+ lastUserMessageRef.current = trimmed;
216
+
217
+ const userMessage: ChatMessage = {
218
+ id: generateId(),
219
+ role: 'user',
220
+ content: trimmed,
221
+ };
222
+
223
+ const assistantMessage: ChatMessage = {
224
+ id: generateId(),
225
+ role: 'assistant',
226
+ content: '',
227
+ isStreaming: true,
228
+ };
229
+
230
+ setMessages((prev) => [
231
+ // Auto-disable any outstanding clarification buttons when user sends a new message
232
+ ...prev.map((m) =>
233
+ m.clarificationOptions && !m.clarificationSelected
234
+ ? { ...m, clarificationSelected: true }
235
+ : m,
236
+ ),
237
+ userMessage,
238
+ assistantMessage,
239
+ ]);
240
+
241
+ // Use messagesRef to avoid stale closure — ref always has current state
242
+ const currentMessages = [...messagesRef.current, userMessage];
243
+ const history = currentMessages.slice(-10).map((msg) => ({
244
+ role: msg.role,
245
+ content: msg.content,
246
+ }));
247
+
248
+ const controller = new AbortController();
249
+ abortControllerRef.current = controller;
250
+
251
+ // When docs are hosted at /docs (subpath proxy), use /docs/_chat so the request
252
+ // goes through existing proxy paths — no Cloudflare Worker update needed.
253
+ // Middleware already handles both /_chat and /docs/_chat.
254
+ const chatUrl = endpoint === '/_chat' && window.location.pathname.startsWith('/docs')
255
+ ? '/docs/_chat'
256
+ : endpoint;
257
+
258
+ try {
259
+ const response = await fetch(chatUrl, {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ message: trimmed, history }),
263
+ signal: controller.signal,
264
+ });
265
+
266
+ if (!response.ok) {
267
+ let errorMessage = 'Something went wrong. Please try again.';
268
+ if (response.status === 429) {
269
+ errorMessage = 'Too many requests. Please wait a moment and try again.';
270
+ } else if (response.status === 503) {
271
+ errorMessage = 'AI chat is temporarily unavailable. Please try again later.';
272
+ }
273
+ setError(errorMessage);
274
+ finishLoading(assistantMessage.id);
275
+ return;
276
+ }
277
+
278
+ await processStream(response, assistantMessage.id);
279
+ } catch (err) {
280
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
281
+ setError('Unable to connect. Check your internet connection and try again.');
282
+ }
283
+ finishLoading(assistantMessage.id);
284
+ }
285
+ },
286
+ [endpoint, processStream, finishLoading],
287
+ );
288
+
289
+ const abort = useCallback(() => {
290
+ if (!isLoadingRef.current) return;
291
+ abortControllerRef.current?.abort();
292
+ }, []);
293
+
294
+ const retry = useCallback(() => {
295
+ if (isLoadingRef.current || !lastUserMessageRef.current) return;
296
+
297
+ const lastMessage = lastUserMessageRef.current;
298
+
299
+ // Remove the last user + assistant message pair before resending
300
+ setMessages((prev) => {
301
+ const remaining = prev.slice();
302
+ if (remaining.at(-1)?.role === 'assistant') remaining.pop();
303
+ if (remaining.at(-1)?.role === 'user') remaining.pop();
304
+ return remaining;
305
+ });
306
+
307
+ // Defer to let the state update from setMessages flush first
308
+ setTimeout(() => sendMessage(lastMessage), 0);
309
+ }, [sendMessage]);
310
+
311
+ const clearChat = useCallback(() => {
312
+ abort();
313
+ setMessages([]);
314
+ setError(null);
315
+ lastUserMessageRef.current = null;
316
+ }, [abort]);
317
+
318
+ /** Mark a clarification message's buttons as selected (disables them). */
319
+ const markClarificationSelected = useCallback((messageId: string) => {
320
+ setMessages((prev) =>
321
+ prev.map((m) => (m.id === messageId ? { ...m, clarificationSelected: true } : m)),
322
+ );
323
+ }, []);
324
+
325
+ return {
326
+ messages,
327
+ sendMessage,
328
+ isLoading,
329
+ abort,
330
+ retry,
331
+ clearChat,
332
+ error,
333
+ markClarificationSelected,
334
+ };
335
+ }
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
4
+
5
+ const MIN_WIDTH = 350;
6
+ const MAX_WIDTH = 500;
7
+ const DEFAULT_WIDTH = 350;
8
+ const STORAGE_KEY = 'jd-chat-width';
9
+
10
+ interface ChatPanelContextValue {
11
+ isChatOpen: boolean;
12
+ setIsChatOpen: (open: boolean) => void;
13
+ chatWidth: number;
14
+ setChatWidth: (width: number) => void;
15
+ chatEnabled: boolean;
16
+ chatEndpoint: string;
17
+ starterQuestions?: string[];
18
+ }
19
+
20
+ const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
21
+
22
+ interface ChatPanelProviderProps {
23
+ children: ReactNode;
24
+ chatEnabled: boolean;
25
+ chatEndpoint: string;
26
+ starterQuestions?: string[];
27
+ }
28
+
29
+ function clampWidth(width: number): number {
30
+ return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
31
+ }
32
+
33
+ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starterQuestions }: ChatPanelProviderProps) {
34
+ const [isChatOpen, setIsChatOpen] = useState(false);
35
+ const [chatWidth, setChatWidthRaw] = useState(DEFAULT_WIDTH);
36
+
37
+ // Load persisted width from localStorage
38
+ useEffect(() => {
39
+ try {
40
+ const stored = localStorage.getItem(STORAGE_KEY);
41
+ if (stored) {
42
+ const parsed = Number(stored);
43
+ if (!Number.isNaN(parsed) && parsed >= MIN_WIDTH && parsed <= MAX_WIDTH) {
44
+ setChatWidthRaw(parsed);
45
+ }
46
+ }
47
+ } catch {
48
+ // localStorage unavailable
49
+ }
50
+ }, []);
51
+
52
+ const setChatWidth = useCallback((width: number) => {
53
+ const clamped = clampWidth(width);
54
+ setChatWidthRaw(clamped);
55
+ try {
56
+ localStorage.setItem(STORAGE_KEY, String(clamped));
57
+ } catch {
58
+ // localStorage unavailable
59
+ }
60
+ }, []);
61
+
62
+ // Keyboard shortcuts: Cmd+I / Ctrl+I to toggle, Escape to close
63
+ // Uses capture phase for Escape so it fires before SearchModal's bubble-phase handler
64
+ useEffect(() => {
65
+ if (!chatEnabled) return;
66
+
67
+ const handler = (e: KeyboardEvent) => {
68
+ // Cmd+I / Ctrl+I — toggle chat
69
+ if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
70
+ e.preventDefault();
71
+ setIsChatOpen(prev => !prev);
72
+ }
73
+ // Escape — close chat (only if chat is open AND search modal isn't open)
74
+ if (e.key === 'Escape' && isChatOpen) {
75
+ const searchOpen = document.querySelector('[aria-label="Search documentation"][role="dialog"]');
76
+ if (searchOpen) return; // Let search modal handle Escape
77
+ e.preventDefault();
78
+ e.stopPropagation();
79
+ setIsChatOpen(false);
80
+ }
81
+ };
82
+ document.addEventListener('keydown', handler, true); // capture phase
83
+ return () => document.removeEventListener('keydown', handler, true);
84
+ }, [chatEnabled, isChatOpen]);
85
+
86
+ return (
87
+ <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions }}>
88
+ {children}
89
+ </ChatPanelContext.Provider>
90
+ );
91
+ }
92
+
93
+ export function useChatPanel(): ChatPanelContextValue {
94
+ const context = useContext(ChatPanelContext);
95
+ if (!context) {
96
+ throw new Error('useChatPanel must be used within a ChatPanelProvider');
97
+ }
98
+ return context;
99
+ }
100
+
101
+ export { MIN_WIDTH, MAX_WIDTH, DEFAULT_WIDTH };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared Anthropic Client
3
+ *
4
+ * Provides a singleton Anthropic SDK client for reuse across the chat route
5
+ * and build-time starter question generation. Reusing a single client allows
6
+ * HTTP connection pooling across requests on the same Cloud Run instance.
7
+ */
8
+ import Anthropic from '@anthropic-ai/sdk';
9
+
10
+ let client: Anthropic | null = null;
11
+
12
+ /** Get or create the shared Anthropic client. Returns null if API key is not configured. */
13
+ export function getAnthropicClient(): Anthropic | null {
14
+ if (!process.env.ANTHROPIC_API_KEY) return null;
15
+ if (!client) {
16
+ client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
17
+ }
18
+ return client;
19
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Extract a CLI-uploaded tarball from R2 into a local directory.
3
+ *
4
+ * Security measures:
5
+ * - execFileSync (not execSync) to prevent shell injection
6
+ * - --no-absolute-filenames to prevent path traversal via absolute paths
7
+ * - Post-extraction symlink validation to prevent symlink escape
8
+ * - Decompressed size limit (500MB) to prevent decompression bombs
9
+ * - 60-second timeout on tar extraction
10
+ */
11
+
12
+ import { execFileSync } from 'child_process';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import { GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
17
+ import { getR2S3Client, getR2Config } from '../r2.js';
18
+ import { logger } from '../../shared/logger.js';
19
+
20
+ const MAX_DECOMPRESSED_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
21
+
22
+ /**
23
+ * Download a tarball from R2, extract it to repoDir, and clean up.
24
+ */
25
+ export async function extractTarball(tarballKey: string, repoDir: string): Promise<void> {
26
+ if (!tarballKey) throw new Error('tarballKey is required');
27
+ if (!repoDir) throw new Error('repoDir is required');
28
+
29
+ const { bucketName } = getR2Config();
30
+ const client = getR2S3Client();
31
+
32
+ // Download tarball from R2 to a temp file
33
+ const response = await client.send(new GetObjectCommand({
34
+ Bucket: bucketName,
35
+ Key: tarballKey,
36
+ }));
37
+
38
+ if (!response.Body) {
39
+ throw new Error(`Tarball not found in R2: ${tarballKey}`);
40
+ }
41
+
42
+ // Write to temp file
43
+ const tmpTarball = path.join(os.tmpdir(), `cli-upload-${Date.now()}.tar.gz`);
44
+
45
+ try {
46
+ const bodyBytes = await response.Body.transformToByteArray();
47
+ fs.writeFileSync(tmpTarball, bodyBytes);
48
+
49
+ // Create target directory
50
+ fs.mkdirSync(repoDir, { recursive: true });
51
+
52
+ // Extract with security flags
53
+ // GNU tar (Linux/Cloud Run) strips leading '/' by default, so no --absolute-names flag needed.
54
+ // --no-same-owner/--no-same-permissions prevent privilege escalation.
55
+ // macOS BSD tar is safe by default (no owner/permission preservation).
56
+ const isLinux = process.platform === 'linux';
57
+ const tarArgs = [
58
+ '-xzf', tmpTarball,
59
+ '-C', repoDir,
60
+ ...(isLinux ? ['--no-same-owner', '--no-same-permissions'] : []),
61
+ ];
62
+ execFileSync('tar', tarArgs, {
63
+ timeout: 60000,
64
+ });
65
+
66
+ // Post-extraction security checks
67
+ validateNoSymlinkEscape(repoDir);
68
+ validateDecompressedSize(repoDir);
69
+
70
+ logger.info('Tarball extracted successfully', {
71
+ tarballKey,
72
+ repoDir,
73
+ });
74
+ } finally {
75
+ // Clean up temp file
76
+ try {
77
+ if (fs.existsSync(tmpTarball)) {
78
+ fs.unlinkSync(tmpTarball);
79
+ }
80
+ } catch {
81
+ // Ignore cleanup errors
82
+ }
83
+ }
84
+
85
+ // Delete tarball from R2 (fire-and-forget)
86
+ client.send(new DeleteObjectCommand({
87
+ Bucket: bucketName,
88
+ Key: tarballKey,
89
+ })).catch((err) => {
90
+ logger.warn('Failed to delete tarball from R2 (non-fatal)', {
91
+ tarballKey,
92
+ error: (err as Error).message,
93
+ });
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Walk repoDir recursively and verify no symlinks point outside it.
99
+ */
100
+ function validateNoSymlinkEscape(repoDir: string): void {
101
+ const realRepoDir = fs.realpathSync(repoDir);
102
+
103
+ function walk(dir: string): void {
104
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ for (const entry of entries) {
106
+ const fullPath = path.join(dir, entry.name);
107
+
108
+ if (entry.isSymbolicLink()) {
109
+ const target = fs.realpathSync(fullPath);
110
+ if (!target.startsWith(realRepoDir + path.sep) && target !== realRepoDir) {
111
+ // Remove the offending symlink and throw
112
+ fs.unlinkSync(fullPath);
113
+ throw new Error(
114
+ `Symlink escape detected: ${path.relative(repoDir, fullPath)} -> ${target} (outside ${realRepoDir})`
115
+ );
116
+ }
117
+ } else if (entry.isDirectory()) {
118
+ walk(fullPath);
119
+ }
120
+ }
121
+ }
122
+
123
+ walk(repoDir);
124
+ }
125
+
126
+ /**
127
+ * Check total decompressed size doesn't exceed limit.
128
+ */
129
+ function validateDecompressedSize(repoDir: string): void {
130
+ let totalSize = 0;
131
+
132
+ function walk(dir: string): void {
133
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
134
+ for (const entry of entries) {
135
+ const fullPath = path.join(dir, entry.name);
136
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
137
+ walk(fullPath);
138
+ } else if (entry.isFile()) {
139
+ totalSize += fs.statSync(fullPath).size;
140
+ if (totalSize > MAX_DECOMPRESSED_SIZE_BYTES) {
141
+ throw new Error(
142
+ `Decompressed size exceeds limit: ${Math.round(totalSize / 1024 / 1024)}MB > ${MAX_DECOMPRESSED_SIZE_BYTES / 1024 / 1024}MB`
143
+ );
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ walk(repoDir);
150
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Chat System Prompt Builder
3
+ *
4
+ * Builds the system prompt for Claude Haiku 4.5, incorporating RAG context
5
+ * from Upstash Vector search results. The prompt instructs Claude to only
6
+ * answer from provided documentation and cite sources with links.
7
+ */
8
+
9
+ export interface ChatContext {
10
+ pageSlug: string;
11
+ sectionHeading: string;
12
+ pageTitle: string;
13
+ content: string;
14
+ score: number;
15
+ }
16
+
17
+ export function buildSystemPrompt(
18
+ projectName: string,
19
+ chunks: ChatContext[],
20
+ baseUrl: string,
21
+ docsPath: string,
22
+ ): string {
23
+ const context = chunks.map((c, i) =>
24
+ `[Source ${i + 1}: ${c.pageTitle} > ${c.sectionHeading}](${baseUrl}${docsPath}/${c.pageSlug})\n${c.content}`
25
+ ).join('\n\n---\n\n');
26
+
27
+ return `You are a helpful documentation assistant for ${projectName}.
28
+ Answer questions using ONLY the documentation context below. If the answer is not in the context, say "I don't have information about that in the documentation."
29
+
30
+ Rules:
31
+ - Be concise and direct
32
+ - Use markdown formatting, including code blocks with language hints when showing code
33
+ - Cite sources by referencing the page title in brackets, e.g. [Getting Started]
34
+ - If unsure, say so rather than guessing
35
+ - Never make up information not in the context
36
+ - DISAMBIGUATION (highest priority): If the context contains multiple distinct features or topics that could match the user's question (e.g., "Post Analytics" vs "Link Analytics" when asked about "analytics"), you MUST ask which one first — even if the user also asked for code examples or details. Your ENTIRE response must follow this EXACT format — nothing else:
37
+
38
+ <question ending with ?>
39
+
40
+ 1. <option>
41
+ 2. <option>
42
+ 3. <option>
43
+
44
+ Rules: question mark required, numbered list required (1. 2. 3.), no descriptions after options, no extra text before or after. Example:
45
+
46
+ Which type of analytics are you interested in?
47
+
48
+ 1. Post Analytics
49
+ 2. Social Analytics
50
+ 3. Link Analytics
51
+ - When the context includes API endpoints (e.g. "POST /analytics/post") or technical operations, proactively include a short code example showing the request (HTTP method, endpoint, JSON body) even if the user didn't explicitly ask for one. Technical users expect to see code. If the context has no code-relevant information, skip the example.
52
+ - When constructing code examples from API endpoints in the context, use the endpoint, HTTP method, and any parameters/body fields mentioned. Always note it's a basic example and link to the full API reference page.
53
+
54
+ Documentation context:
55
+ ${context}`;
56
+ }
@@ -617,6 +617,16 @@ export interface SearchConfig {
617
617
  }>;
618
618
  }
619
619
 
620
+ /**
621
+ * AI Chat configuration
622
+ */
623
+ export interface ChatConfig {
624
+ /** Enable AI chat assistant (default: false) */
625
+ enabled?: boolean;
626
+ /** Starter questions shown in empty state (max 4). Auto-generated by Haiku during builds when omitted. Set to [] to disable. */
627
+ starterQuestions?: string[];
628
+ }
629
+
620
630
  /**
621
631
  * SEO configuration
622
632
  */
@@ -725,6 +735,9 @@ export interface DocsConfig {
725
735
  // Schema reference
726
736
  $schema?: string;
727
737
 
738
+ // Meta fields (set by CLI)
739
+ projectId?: string;
740
+
728
741
  // Required fields
729
742
  theme: ThemeName;
730
743
  name: string;
@@ -774,6 +787,7 @@ export interface DocsConfig {
774
787
  interaction?: InteractionConfig;
775
788
  metadata?: MetadataConfig;
776
789
  analytics?: AnalyticsConfig;
790
+ chat?: ChatConfig;
777
791
 
778
792
  // Mintlify compatibility fields (normalized at load time)
779
793
  modeToggle?: ModeToggleConfig;