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.
- package/README.md +89 -4
- package/dist/__tests__/unit/auth.test.d.ts +2 -0
- package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/auth.test.js +169 -0
- package/dist/__tests__/unit/auth.test.js.map +1 -0
- package/dist/__tests__/unit/config.test.d.ts +2 -0
- package/dist/__tests__/unit/config.test.d.ts.map +1 -0
- package/dist/__tests__/unit/config.test.js +76 -0
- package/dist/__tests__/unit/config.test.js.map +1 -0
- package/dist/__tests__/unit/deploy.test.d.ts +2 -0
- package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
- package/dist/__tests__/unit/deploy.test.js +273 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -0
- package/dist/__tests__/unit/deps-sync.test.js +3 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
- package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
- package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
- package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
- package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
- package/dist/__tests__/unit/loading-page.test.js +73 -0
- package/dist/__tests__/unit/loading-page.test.js.map +1 -0
- package/dist/__tests__/unit/login.test.d.ts +2 -0
- package/dist/__tests__/unit/login.test.d.ts.map +1 -0
- package/dist/__tests__/unit/login.test.js +100 -0
- package/dist/__tests__/unit/login.test.js.map +1 -0
- package/dist/__tests__/unit/logout.test.d.ts +2 -0
- package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logout.test.js +39 -0
- package/dist/__tests__/unit/logout.test.js.map +1 -0
- package/dist/__tests__/unit/tarball.test.d.ts +2 -0
- package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
- package/dist/__tests__/unit/tarball.test.js +126 -0
- package/dist/__tests__/unit/tarball.test.js.map +1 -0
- package/dist/__tests__/unit/whoami.test.d.ts +2 -0
- package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
- package/dist/__tests__/unit/whoami.test.js +47 -0
- package/dist/__tests__/unit/whoami.test.js.map +1 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +265 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +48 -25
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +135 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +50 -7
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.d.ts +34 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +105 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +7 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/dev-loading-server.d.ts +22 -0
- package/dist/lib/dev-loading-server.d.ts.map +1 -0
- package/dist/lib/dev-loading-server.js +117 -0
- package/dist/lib/dev-loading-server.js.map +1 -0
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/docs-json-writer.d.ts +2 -0
- package/dist/lib/docs-json-writer.d.ts.map +1 -0
- package/dist/lib/docs-json-writer.js +35 -0
- package/dist/lib/docs-json-writer.js.map +1 -0
- package/dist/lib/loading-page.d.ts +11 -0
- package/dist/lib/loading-page.d.ts.map +1 -0
- package/dist/lib/loading-page.js +222 -0
- package/dist/lib/loading-page.js.map +1 -0
- package/dist/lib/output.d.ts +13 -5
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +22 -5
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/tarball.d.ts +28 -0
- package/dist/lib/tarball.d.ts.map +1 -0
- package/dist/lib/tarball.js +117 -0
- package/dist/lib/tarball.js.map +1 -0
- package/package.json +5 -2
- package/vendored/app/[[...slug]]/page.tsx +6 -20
- package/vendored/app/api/chat/[project]/route.ts +323 -0
- package/vendored/app/api/mcp/[project]/route.ts +2 -63
- package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
- package/vendored/components/chat/ChatEmptyState.tsx +79 -0
- package/vendored/components/chat/ChatFAB.tsx +36 -0
- package/vendored/components/chat/ChatInput.tsx +106 -0
- package/vendored/components/chat/ChatMessage.tsx +176 -0
- package/vendored/components/chat/ChatPanel.tsx +206 -0
- package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
- package/vendored/components/chat/LazyChatPanel.tsx +19 -0
- package/vendored/components/layout/LayoutWrapper.tsx +134 -44
- package/vendored/components/layout/PageColumns.tsx +40 -0
- package/vendored/components/navigation/Header.tsx +74 -29
- package/vendored/components/navigation/Sidebar.tsx +17 -2
- package/vendored/hooks/useChat.ts +335 -0
- package/vendored/hooks/useChatPanel.tsx +101 -0
- package/vendored/lib/anthropic-client.ts +19 -0
- package/vendored/lib/build/extract-tarball.ts +150 -0
- package/vendored/lib/chat-prompt.ts +56 -0
- package/vendored/lib/docs-types.ts +14 -0
- package/vendored/lib/docs.ts +22 -4
- package/vendored/lib/embedding-chunker.ts +173 -0
- package/vendored/lib/generate-starter-questions.ts +98 -0
- package/vendored/lib/isr-build-executor.ts +2 -1
- package/vendored/lib/middleware-helpers.ts +21 -0
- package/vendored/lib/route-helpers.ts +96 -0
- package/vendored/lib/snippet-loader-isr.ts +107 -1
- package/vendored/lib/static-artifacts.ts +3 -2
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/lib/vector-store.ts +213 -0
- package/vendored/schema/docs-schema.json +33 -0
- package/vendored/scripts/dev-project.cjs +6 -0
- package/vendored/shared/types.ts +6 -5
- package/vendored/tailwind.config.ts +9 -0
- 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;
|