jamdesk 1.1.28 → 1.1.30
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 +3 -1
- package/dist/lib/deps.js +3 -3
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +36 -109
- package/vendored/app/api/assets/[...path]/route.ts +2 -2
- package/vendored/app/api/chat/[project]/route.ts +206 -138
- package/vendored/app/api/isr-health/route.ts +2 -3
- package/vendored/app/layout.tsx +2 -0
- package/vendored/components/JdReadySentinel.tsx +25 -0
- package/vendored/components/chat/ChatEmptyState.tsx +13 -1
- package/vendored/components/chat/ChatPanel.tsx +43 -9
- package/vendored/components/navigation/Breadcrumb.tsx +2 -2
- package/vendored/components/navigation/Header.tsx +23 -17
- package/vendored/components/navigation/LanguageSelector.tsx +7 -4
- package/vendored/components/navigation/Sidebar.tsx +28 -37
- package/vendored/components/navigation/TabsNav.tsx +1 -1
- package/vendored/hooks/useChat.ts +113 -60
- package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
- package/vendored/hooks/useTextStreamPacer.ts +152 -0
- package/vendored/lib/chat-prompt.ts +69 -29
- package/vendored/lib/chat-tools.ts +111 -0
- package/vendored/lib/crisp-bridge.ts +91 -0
- package/vendored/lib/docs-types.ts +4 -0
- package/vendored/lib/embedding-chunker.ts +85 -11
- package/vendored/lib/find-first-nav-page.ts +40 -0
- package/vendored/lib/hedge-strip.ts +29 -0
- package/vendored/lib/middleware-helpers.ts +2 -1
- package/vendored/lib/openapi/lang-spec-path.ts +16 -0
- package/vendored/lib/page-isr-helpers.ts +4 -1
- package/vendored/lib/public-paths-resolver.ts +3 -42
- package/vendored/lib/query-rewriter.ts +91 -0
- package/vendored/lib/ui-strings.ts +52 -0
- package/vendored/lib/vector-store.ts +5 -3
- package/vendored/schema/docs-schema.json +15 -0
- package/vendored/workspace-package-lock.json +88 -88
|
@@ -22,11 +22,15 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { NextRequest } from 'next/server';
|
|
24
24
|
import { querySimilarChunks } from '@/lib/vector-store';
|
|
25
|
-
import { buildSystemPrompt,
|
|
25
|
+
import { buildSystemPrompt, isCannedIdentityReply, resolveSiteName } from '@/lib/chat-prompt';
|
|
26
|
+
import { stripHedge, isHedgeReply } from '@/lib/hedge-strip';
|
|
26
27
|
import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers';
|
|
27
28
|
import { getAnthropicClient } from '@/lib/anthropic-client';
|
|
29
|
+
import { rewriteQueryForSearch } from '@/lib/query-rewriter';
|
|
28
30
|
import { fetchDocsConfig } from '@/lib/r2-content';
|
|
29
31
|
import { redis } from '@/lib/redis';
|
|
32
|
+
import { CHAT_TOOLS } from '@/lib/chat-tools';
|
|
33
|
+
import { log } from '@/lib/logger';
|
|
30
34
|
|
|
31
35
|
export const runtime = 'nodejs';
|
|
32
36
|
export const maxDuration = 30;
|
|
@@ -35,115 +39,11 @@ const CHAT_MODEL = 'claude-haiku-4-5-20251001';
|
|
|
35
39
|
const RATE_LIMIT = 10;
|
|
36
40
|
const RATE_WINDOW_SECONDS = 60;
|
|
37
41
|
const MAX_HISTORY = 10;
|
|
42
|
+
/** Messages shorter than this with prior history are treated as follow-ups.
|
|
43
|
+
* Used both for searchQuery enrichment and to skip the rewriter. */
|
|
44
|
+
const SHORT_FOLLOWUP_LEN = 60;
|
|
38
45
|
const VALID_ROLES = new Set<string>(['user', 'assistant']);
|
|
39
46
|
|
|
40
|
-
/**
|
|
41
|
-
* Extract citations from Claude's response by matching [Title] references
|
|
42
|
-
* against the vector search chunks. Deduplicates by page slug.
|
|
43
|
-
* Falls back to top 2 unique pages by score if Claude didn't cite anything.
|
|
44
|
-
*/
|
|
45
|
-
type Citation = { title: string; slug: string; section?: string };
|
|
46
|
-
type ScoredChunk = { pageSlug: string; pageTitle: string; sectionHeading: string; score: number };
|
|
47
|
-
|
|
48
|
-
function extractCitations(
|
|
49
|
-
responseText: string,
|
|
50
|
-
chunks: ScoredChunk[],
|
|
51
|
-
): { sources: Citation[]; hadExplicitCitations: boolean } {
|
|
52
|
-
// Match [Title] but not markdown links [text](url)
|
|
53
|
-
const referencedTitles = new Set(
|
|
54
|
-
Array.from(responseText.matchAll(/\[([^\]]+)\](?!\()/g), (m) => m[1]),
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const seen = new Set<string>();
|
|
58
|
-
const sources: Citation[] = [];
|
|
59
|
-
|
|
60
|
-
function addUniqueChunk(c: (typeof chunks)[number]): void {
|
|
61
|
-
if (seen.has(c.pageSlug)) return;
|
|
62
|
-
seen.add(c.pageSlug);
|
|
63
|
-
sources.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Filter chunks to only those Claude referenced, deduplicate by slug
|
|
67
|
-
for (const c of chunks) {
|
|
68
|
-
if (referencedTitles.has(c.pageTitle)) addUniqueChunk(c);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Record whether Claude explicitly cited sources before the fallback
|
|
72
|
-
const hadExplicitCitations = sources.length > 0;
|
|
73
|
-
|
|
74
|
-
// Fallback: if Claude didn't cite anything explicitly, show top 2 unique pages by score
|
|
75
|
-
if (sources.length === 0) {
|
|
76
|
-
for (const c of chunks) {
|
|
77
|
-
addUniqueChunk(c);
|
|
78
|
-
if (sources.length >= 2) break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return { sources, hadExplicitCitations };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Detect option lists in Claude's response that indicate a clarification question.
|
|
87
|
-
* Returns extracted option strings, or null if the response is a normal answer.
|
|
88
|
-
*
|
|
89
|
-
* Heuristic guards to avoid false positives on instructional lists:
|
|
90
|
-
* - Response must contain a question mark or colon (clarification Qs ask something)
|
|
91
|
-
* - Response must be short (< 500 chars — clarification Qs are brief)
|
|
92
|
-
* - Must be 2-3 items (instructional lists tend to be longer)
|
|
93
|
-
* - List must be at the END of the response
|
|
94
|
-
*
|
|
95
|
-
* Supports numbered (1. 2. 3.) and unnumbered (plain lines) formats.
|
|
96
|
-
* Strips markdown formatting (bold, backticks) from option text.
|
|
97
|
-
*/
|
|
98
|
-
export function extractClarificationOptions(responseText: string): string[] | null {
|
|
99
|
-
// Trim trailing whitespace/newlines so the $ anchor in option-list regexes
|
|
100
|
-
// matches reliably — SSE text deltas can include trailing newlines.
|
|
101
|
-
const text = responseText.trimEnd();
|
|
102
|
-
|
|
103
|
-
// Must indicate a question. A `?` always qualifies. A `:` only qualifies when
|
|
104
|
-
// the preamble contains clarification words (to avoid false positives on
|
|
105
|
-
// instructional lists like "To enable dark mode:\n\n1. Open Settings").
|
|
106
|
-
const hasQuestion = text.includes('?');
|
|
107
|
-
const hasClarificationColon = !hasQuestion
|
|
108
|
-
&& text.includes(':')
|
|
109
|
-
&& /\b(which|what|clarify|interested|looking for|asking|type of|kind of)\b/i.test(text);
|
|
110
|
-
if (!hasQuestion && !hasClarificationColon) return null;
|
|
111
|
-
|
|
112
|
-
// Only short responses are likely clarification questions
|
|
113
|
-
if (text.length > 500) return null;
|
|
114
|
-
|
|
115
|
-
// Strip markdown formatting (bold, italic, backticks) from option labels
|
|
116
|
-
function cleanOption(text: string): string {
|
|
117
|
-
return text.trim().replace(/[*`]/g, '');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Primary: numbered list (1. 2. 3.) at the end
|
|
121
|
-
const numbered = text.match(
|
|
122
|
-
/\n\n1\.\s+(.+)\n2\.\s+(.+)(?:\n3\.\s+(.+))?$/,
|
|
123
|
-
);
|
|
124
|
-
if (numbered) {
|
|
125
|
-
const options = [cleanOption(numbered[1]), cleanOption(numbered[2])];
|
|
126
|
-
if (numbered[3]) options.push(cleanOption(numbered[3]));
|
|
127
|
-
return options;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Fallback: unnumbered list (2-3 short lines at the end, after a blank line)
|
|
131
|
-
// Each line must be short (< 80 chars) and non-empty to distinguish from paragraphs
|
|
132
|
-
const unnumbered = text.match(
|
|
133
|
-
/\n\n([^\n]{2,80})\n([^\n]{2,80})(?:\n([^\n]{2,80}))?$/,
|
|
134
|
-
);
|
|
135
|
-
if (unnumbered) {
|
|
136
|
-
// Extra guard: lines must NOT look like sentences (no periods at end)
|
|
137
|
-
const lines = [unnumbered[1], unnumbered[2], unnumbered[3]].filter(Boolean) as string[];
|
|
138
|
-
const looksLikeOptions = lines.every(l => !l.trim().endsWith('.') && l.trim().length < 80);
|
|
139
|
-
if (looksLikeOptions && lines.length >= 2) {
|
|
140
|
-
return lines.map(cleanOption);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
47
|
export async function POST(
|
|
148
48
|
request: NextRequest,
|
|
149
49
|
context: { params: Promise<{ project: string }> },
|
|
@@ -197,11 +97,12 @@ export async function POST(
|
|
|
197
97
|
})
|
|
198
98
|
.map((h) => ({ role: h.role, content: h.content.slice(0, 4000) }));
|
|
199
99
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
100
|
+
const isShortFollowUp = message.length < SHORT_FOLLOWUP_LEN && history.length > 0;
|
|
101
|
+
|
|
102
|
+
// When a user picks a clarification option (e.g. "Post Analytics"), the
|
|
103
|
+
// message alone is too short for good retrieval — combine with the prior turn.
|
|
203
104
|
let searchQuery = message;
|
|
204
|
-
if (
|
|
105
|
+
if (isShortFollowUp) {
|
|
205
106
|
const prevUserMsg = [...history].reverse().find(
|
|
206
107
|
h => h.role === 'user' && h.content !== message,
|
|
207
108
|
);
|
|
@@ -221,9 +122,39 @@ export async function POST(
|
|
|
221
122
|
fetchDocsConfig(project).catch(() => null),
|
|
222
123
|
]);
|
|
223
124
|
|
|
224
|
-
|
|
125
|
+
// Fully parallel retrieval:
|
|
126
|
+
// - Original vector query runs immediately.
|
|
127
|
+
// - Rewriter + rewritten-query vector search are chained into ONE promise,
|
|
128
|
+
// then Promise.all'd with the original. Critical path is
|
|
129
|
+
// max(original_query_time, rewriter_time + rewritten_query_time), not
|
|
130
|
+
// max(original, rewriter) + rewritten_query_time.
|
|
131
|
+
// Any failure in the rewrite path resolves to an empty chunk list — the
|
|
132
|
+
// original query still returns results so chat doesn't fail.
|
|
133
|
+
const originalQueryPromise = querySimilarChunks(project, searchQuery, 15);
|
|
134
|
+
|
|
135
|
+
// Skipping the rewriter on short follow-ups avoids 500-2000ms of serial
|
|
136
|
+
// Anthropic latency — `searchQuery` above is already enriched with prior-turn
|
|
137
|
+
// context using the same threshold, so the rewrite has no retrieval benefit.
|
|
138
|
+
const rewrittenPathPromise: Promise<Awaited<ReturnType<typeof querySimilarChunks>>> =
|
|
139
|
+
isShortFollowUp
|
|
140
|
+
? Promise.resolve([])
|
|
141
|
+
: rewriteQueryForSearch(message, history)
|
|
142
|
+
.catch(() => null)
|
|
143
|
+
.then(rewritten => {
|
|
144
|
+
// Compare against `message` (the rewriter's input), not `searchQuery`
|
|
145
|
+
// (which is enriched with prior-turn context for short follow-ups).
|
|
146
|
+
// The rewriter only sees `message`, so a no-op rewrite equals `message`.
|
|
147
|
+
if (!rewritten || rewritten === message) return [];
|
|
148
|
+
return querySimilarChunks(project, rewritten, 15).catch(() => []);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
152
|
+
let rewrittenChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
225
153
|
try {
|
|
226
|
-
|
|
154
|
+
[originalChunks, rewrittenChunks] = await Promise.all([
|
|
155
|
+
originalQueryPromise,
|
|
156
|
+
rewrittenPathPromise,
|
|
157
|
+
]);
|
|
227
158
|
} catch {
|
|
228
159
|
return Response.json(
|
|
229
160
|
{ error: 'AI chat is not available for this project.' },
|
|
@@ -231,6 +162,21 @@ export async function POST(
|
|
|
231
162
|
);
|
|
232
163
|
}
|
|
233
164
|
|
|
165
|
+
// Merge: original chunks first (they match the user's literal phrasing),
|
|
166
|
+
// then unique chunks from the rewritten query. Dedup by pageSlug +
|
|
167
|
+
// sectionHeading — chunks with the same identity are interchangeable across
|
|
168
|
+
// queries.
|
|
169
|
+
const seenKeys = new Set(originalChunks.map(c => `${c.pageSlug}#${c.sectionHeading}`));
|
|
170
|
+
const chunks = [
|
|
171
|
+
...originalChunks,
|
|
172
|
+
...rewrittenChunks.filter(c => {
|
|
173
|
+
const key = `${c.pageSlug}#${c.sectionHeading}`;
|
|
174
|
+
if (seenKeys.has(key)) return false;
|
|
175
|
+
seenKeys.add(key);
|
|
176
|
+
return true;
|
|
177
|
+
}),
|
|
178
|
+
];
|
|
179
|
+
|
|
234
180
|
if (chunks.length === 0) {
|
|
235
181
|
return Response.json(
|
|
236
182
|
{ error: 'No documentation content found for this project.' },
|
|
@@ -257,6 +203,8 @@ export async function POST(
|
|
|
257
203
|
max_tokens: 2048,
|
|
258
204
|
temperature: 0.3,
|
|
259
205
|
system: systemPrompt,
|
|
206
|
+
tools: CHAT_TOOLS,
|
|
207
|
+
tool_choice: { type: 'any' },
|
|
260
208
|
messages: [
|
|
261
209
|
...history.slice(-MAX_HISTORY),
|
|
262
210
|
{ role: 'user', content: message },
|
|
@@ -268,43 +216,157 @@ export async function POST(
|
|
|
268
216
|
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
|
269
217
|
}
|
|
270
218
|
|
|
219
|
+
type Citation = { title: string; slug: string; section?: string };
|
|
220
|
+
type AnswerInput = { markdown?: string; cited_page_slugs?: string[] };
|
|
221
|
+
type ClarificationInput = { question?: string; options?: string[] };
|
|
222
|
+
|
|
223
|
+
function topChunksAsCitations(limit = 2, skip?: Set<string>): Citation[] {
|
|
224
|
+
const out: Citation[] = [];
|
|
225
|
+
const seen = new Set<string>(skip);
|
|
226
|
+
for (const c of chunks) {
|
|
227
|
+
if (seen.has(c.pageSlug)) continue;
|
|
228
|
+
seen.add(c.pageSlug);
|
|
229
|
+
out.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
|
|
230
|
+
if (out.length >= limit) break;
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
271
235
|
const readable = new ReadableStream({
|
|
272
236
|
async start(controller) {
|
|
273
|
-
let
|
|
237
|
+
let toolName: string | null = null;
|
|
238
|
+
let emittedMarkdownLength = 0;
|
|
239
|
+
let emittedQuestionLength = 0;
|
|
240
|
+
|
|
241
|
+
// Incrementally extract a still-open string field from partial tool_use JSON.
|
|
242
|
+
// The SDK's jsonSnapshot only exposes a field once its string literal closes,
|
|
243
|
+
// so a ~1000-char answer lands in one snapshot update and the client sees it
|
|
244
|
+
// in one flash. Instead, accumulate the raw partial_json and close the string
|
|
245
|
+
// + object ourselves to read the in-progress value.
|
|
246
|
+
let accumulatedJson = '';
|
|
247
|
+
function extractPartialString(field: 'markdown' | 'question'): string | null {
|
|
248
|
+
// A trailing odd number of backslashes means we're mid-escape. Appending
|
|
249
|
+
// `"}` would turn `\` into `\"` — a valid escaped quote — producing a
|
|
250
|
+
// bogus literal `"` in the extracted string that gets streamed as text.
|
|
251
|
+
const trailing = accumulatedJson.match(/\\+$/)?.[0].length ?? 0;
|
|
252
|
+
if (trailing % 2 !== 0) return null;
|
|
253
|
+
try {
|
|
254
|
+
const parsed = JSON.parse(accumulatedJson + '"}') as Record<string, unknown>;
|
|
255
|
+
const value = parsed[field];
|
|
256
|
+
return typeof value === 'string' ? value : null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stream.on('inputJson', (partialJson, jsonSnapshot) => {
|
|
263
|
+
accumulatedJson += typeof partialJson === 'string' ? partialJson : '';
|
|
264
|
+
|
|
265
|
+
// Gate on toolName — emitting before content_block_start resolves
|
|
266
|
+
// would leak clarification JSON into the answer text channel.
|
|
267
|
+
if (toolName === 'answer') {
|
|
268
|
+
const snap = jsonSnapshot as AnswerInput | null | undefined;
|
|
269
|
+
const md = typeof snap?.markdown === 'string'
|
|
270
|
+
? snap.markdown
|
|
271
|
+
: extractPartialString('markdown');
|
|
272
|
+
if (md !== null) {
|
|
273
|
+
const trimmed = stripHedge(md);
|
|
274
|
+
if (trimmed.length > emittedMarkdownLength) {
|
|
275
|
+
const newContent = trimmed.slice(emittedMarkdownLength);
|
|
276
|
+
emittedMarkdownLength = trimmed.length;
|
|
277
|
+
controller.enqueue(sendEvent({ type: 'text', content: newContent }));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} else if (toolName === 'ask_clarification') {
|
|
281
|
+
const snap = jsonSnapshot as ClarificationInput | null | undefined;
|
|
282
|
+
const q = typeof snap?.question === 'string'
|
|
283
|
+
? snap.question
|
|
284
|
+
: extractPartialString('question');
|
|
285
|
+
if (q !== null && q.length > emittedQuestionLength) {
|
|
286
|
+
const newContent = q.slice(emittedQuestionLength);
|
|
287
|
+
emittedQuestionLength = q.length;
|
|
288
|
+
controller.enqueue(sendEvent({ type: 'text', content: newContent }));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
274
292
|
|
|
275
293
|
try {
|
|
294
|
+
// Watch for content_block_start to learn which tool Claude picked.
|
|
276
295
|
for await (const event of stream) {
|
|
277
|
-
if (event.type === '
|
|
278
|
-
|
|
279
|
-
controller.enqueue(sendEvent({ type: 'text', content: event.delta.text }));
|
|
296
|
+
if (event.type === 'content_block_start' && event.content_block.type === 'tool_use') {
|
|
297
|
+
toolName = event.content_block.name;
|
|
280
298
|
}
|
|
281
299
|
}
|
|
282
300
|
|
|
283
|
-
// After streaming completes, get token usage
|
|
284
301
|
const finalMessage = await stream.finalMessage();
|
|
285
302
|
const inputTokens = finalMessage.usage?.input_tokens ?? 0;
|
|
286
303
|
const outputTokens = finalMessage.usage?.output_tokens ?? 0;
|
|
287
304
|
|
|
288
|
-
//
|
|
289
|
-
const
|
|
290
|
-
if (
|
|
305
|
+
// With tool_choice: 'any' there is exactly one tool_use block.
|
|
306
|
+
const toolUse = finalMessage.content.find(c => c.type === 'tool_use');
|
|
307
|
+
if (!toolUse || toolUse.type !== 'tool_use') {
|
|
308
|
+
throw new Error('Expected tool_use block in final message');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let sources: Citation[];
|
|
312
|
+
let hadExplicitCitations: boolean;
|
|
313
|
+
const hasClarification = toolUse.name === 'ask_clarification';
|
|
314
|
+
|
|
315
|
+
if (toolUse.name === 'answer') {
|
|
316
|
+
const input = toolUse.input as AnswerInput;
|
|
317
|
+
const slugs = Array.isArray(input.cited_page_slugs) ? input.cited_page_slugs : [];
|
|
318
|
+
const rawMarkdown = typeof input.markdown === 'string' ? input.markdown : '';
|
|
319
|
+
const markdownText = stripHedge(rawMarkdown);
|
|
320
|
+
// Suppress citations on identity + hedge replies — the top-2 fallback
|
|
321
|
+
// would otherwise attach unrelated docs under "I'm the documentation
|
|
322
|
+
// assistant…" or under "I don't have information…".
|
|
323
|
+
const suppressCitations =
|
|
324
|
+
isCannedIdentityReply(markdownText, siteName) || isHedgeReply(rawMarkdown);
|
|
325
|
+
|
|
326
|
+
const seen = new Set<string>();
|
|
327
|
+
const explicitSources: Citation[] = [];
|
|
328
|
+
if (!suppressCitations) {
|
|
329
|
+
for (const slug of slugs) {
|
|
330
|
+
const chunk = chunks.find(c => c.pageSlug === slug);
|
|
331
|
+
if (chunk && !seen.has(chunk.pageSlug)) {
|
|
332
|
+
seen.add(chunk.pageSlug);
|
|
333
|
+
explicitSources.push({
|
|
334
|
+
title: chunk.pageTitle,
|
|
335
|
+
slug: chunk.pageSlug,
|
|
336
|
+
section: chunk.sectionHeading,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
hadExplicitCitations = suppressCitations || explicitSources.length > 0;
|
|
342
|
+
sources = suppressCitations
|
|
343
|
+
? []
|
|
344
|
+
: explicitSources.length > 0
|
|
345
|
+
? explicitSources
|
|
346
|
+
: topChunksAsCitations(2, seen);
|
|
347
|
+
} else if (toolUse.name === 'ask_clarification') {
|
|
348
|
+
const input = toolUse.input as ClarificationInput;
|
|
349
|
+
const options = Array.isArray(input.options) ? input.options : [];
|
|
350
|
+
const question = typeof input.question === 'string' ? input.question.trim() : '';
|
|
351
|
+
// Missing question → UI shows buttons with no prompt.
|
|
352
|
+
// <2 options → dead-end UI. Interrupt so the user can retry.
|
|
353
|
+
if (!question) {
|
|
354
|
+
throw new Error('ask_clarification returned no question');
|
|
355
|
+
}
|
|
356
|
+
if (options.length < 2) {
|
|
357
|
+
throw new Error(`ask_clarification returned ${options.length} options; minimum is 2`);
|
|
358
|
+
}
|
|
291
359
|
controller.enqueue(sendEvent({ type: 'clarification', options }));
|
|
360
|
+
// Surface top chunks so users see where the disambiguation candidates come from.
|
|
361
|
+
sources = topChunksAsCitations();
|
|
362
|
+
hadExplicitCitations = false;
|
|
363
|
+
} else {
|
|
364
|
+
throw new Error(`Unexpected tool name: ${toolUse.name}`);
|
|
292
365
|
}
|
|
293
366
|
|
|
294
|
-
|
|
295
|
-
const { sources, hadExplicitCitations } = extractCitations(fullText, chunks);
|
|
296
|
-
|
|
297
|
-
// If Claude returned the canned identity reply, suppress citations — otherwise
|
|
298
|
-
// the "top 2 by score" fallback in extractCitations would attach unrelated
|
|
299
|
-
// docs sources under "I'm the documentation assistant for …".
|
|
300
|
-
const isIdentityReply = fullText.trim() === buildCannedIdentityReply(siteName);
|
|
301
|
-
controller.enqueue(sendEvent({
|
|
302
|
-
type: 'citations',
|
|
303
|
-
sources: isIdentityReply ? [] : sources,
|
|
304
|
-
}));
|
|
367
|
+
controller.enqueue(sendEvent({ type: 'citations', sources }));
|
|
305
368
|
controller.enqueue(sendEvent({ type: 'done' }));
|
|
306
369
|
|
|
307
|
-
// Fire-and-forget analytics — only on success (finalMessage unreliable on error)
|
|
308
370
|
trackChatAnalytics({
|
|
309
371
|
projectSlug: project,
|
|
310
372
|
query: message,
|
|
@@ -312,12 +374,17 @@ export async function POST(
|
|
|
312
374
|
inputTokens,
|
|
313
375
|
outputTokens,
|
|
314
376
|
model: CHAT_MODEL,
|
|
315
|
-
hadExplicitCitations
|
|
316
|
-
hasClarification
|
|
377
|
+
hadExplicitCitations,
|
|
378
|
+
hasClarification,
|
|
317
379
|
durationMs: Date.now() - startTime,
|
|
318
380
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
319
381
|
}).catch(() => {});
|
|
320
|
-
} catch {
|
|
382
|
+
} catch (err) {
|
|
383
|
+
log('warn', 'chat: stream interrupted', {
|
|
384
|
+
projectSlug: project,
|
|
385
|
+
toolName,
|
|
386
|
+
error: err instanceof Error ? err.message : String(err),
|
|
387
|
+
});
|
|
321
388
|
controller.enqueue(sendEvent({ type: 'error', message: 'The response was interrupted. Please try again.' }));
|
|
322
389
|
}
|
|
323
390
|
controller.close();
|
|
@@ -327,8 +394,9 @@ export async function POST(
|
|
|
327
394
|
return new Response(readable, {
|
|
328
395
|
headers: {
|
|
329
396
|
'Content-Type': 'text/event-stream',
|
|
330
|
-
'Cache-Control': 'no-cache',
|
|
397
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
331
398
|
'Connection': 'keep-alive',
|
|
399
|
+
'X-Accel-Buffering': 'no',
|
|
332
400
|
},
|
|
333
401
|
});
|
|
334
402
|
}
|
|
@@ -9,6 +9,7 @@ import { NextResponse } from 'next/server';
|
|
|
9
9
|
import { getConfigCacheSize } from '@/lib/r2-content';
|
|
10
10
|
import { getSnippetCacheSize } from '@/lib/snippet-compiler-isr';
|
|
11
11
|
import { getOpenApiCacheSize } from '@/lib/openapi-isr';
|
|
12
|
+
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
12
13
|
|
|
13
14
|
interface HealthCheck {
|
|
14
15
|
status: 'ok' | 'degraded' | 'unhealthy';
|
|
@@ -31,8 +32,6 @@ interface HealthCheck {
|
|
|
31
32
|
const startTime = Date.now();
|
|
32
33
|
|
|
33
34
|
export async function GET() {
|
|
34
|
-
const isIsrMode = process.env.ISR_MODE === 'true';
|
|
35
|
-
|
|
36
35
|
// Memory usage
|
|
37
36
|
const memUsage = process.memoryUsage();
|
|
38
37
|
const memory = {
|
|
@@ -54,7 +53,7 @@ export async function GET() {
|
|
|
54
53
|
memory,
|
|
55
54
|
uptime: Math.round((Date.now() - startTime) / 1000),
|
|
56
55
|
timestamp: new Date().toISOString(),
|
|
57
|
-
isrMode: isIsrMode,
|
|
56
|
+
isrMode: isIsrMode(),
|
|
58
57
|
};
|
|
59
58
|
|
|
60
59
|
return NextResponse.json(health, {
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
|
|
|
7
7
|
import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
|
|
8
8
|
import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
|
|
9
9
|
import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
|
|
10
|
+
import { JdReadySentinel } from '@/components/JdReadySentinel';
|
|
10
11
|
import { FA_CSS_HREF } from '@/lib/font-awesome';
|
|
11
12
|
import { getDocsConfig } from '@/lib/docs';
|
|
12
13
|
import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
|
|
@@ -580,6 +581,7 @@ export default async function RootLayout({
|
|
|
580
581
|
{config.integrations?.ga4?.measurementId && (
|
|
581
582
|
<ConditionalGA gaId={config.integrations.ga4.measurementId} />
|
|
582
583
|
)}
|
|
584
|
+
<JdReadySentinel />
|
|
583
585
|
</body>
|
|
584
586
|
</html>
|
|
585
587
|
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sets document.body.dataset.jdReady = 'true' after window.load so the
|
|
7
|
+
* pdf-service (and any future browser automation) can wait on a reliable
|
|
8
|
+
* signal that the page is fully rendered and resources have settled.
|
|
9
|
+
*/
|
|
10
|
+
export function JdReadySentinel(): null {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const mark = () => {
|
|
13
|
+
document.body.dataset.jdReady = 'true';
|
|
14
|
+
};
|
|
15
|
+
if (document.readyState === 'complete') {
|
|
16
|
+
mark();
|
|
17
|
+
} else {
|
|
18
|
+
window.addEventListener('load', mark, { once: true });
|
|
19
|
+
}
|
|
20
|
+
// removeEventListener with an unregistered listener is a no-op, so the
|
|
21
|
+
// cleanup is safe whether or not the branch above registered it.
|
|
22
|
+
return () => window.removeEventListener('load', mark);
|
|
23
|
+
}, []);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -5,6 +5,9 @@ import { memo, useMemo, useState, useCallback } from 'react';
|
|
|
5
5
|
interface ChatEmptyStateProps {
|
|
6
6
|
starterQuestions?: string[];
|
|
7
7
|
onSelectQuestion: (question: string) => void;
|
|
8
|
+
/** Escape-hatch to hand the visitor off to Crisp. Omit to hide the button
|
|
9
|
+
* (e.g. when Crisp isn't loaded). Detection happens in ChatPanel. */
|
|
10
|
+
onTalkToHuman?: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
const STARTER_BASE = 'w-full text-left px-4 py-3 rounded-xl border text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]';
|
|
@@ -25,7 +28,7 @@ function starterButtonClass(question: string, selectedQuestion: string | null):
|
|
|
25
28
|
* Starter questions are auto-generated by Haiku during builds when not
|
|
26
29
|
* defined in docs.json — see lib/generate-starter-questions.ts.
|
|
27
30
|
*/
|
|
28
|
-
export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, onSelectQuestion }: ChatEmptyStateProps) {
|
|
31
|
+
export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, onSelectQuestion, onTalkToHuman }: ChatEmptyStateProps) {
|
|
29
32
|
const shortcutKey = useMemo(
|
|
30
33
|
() => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
|
|
31
34
|
[],
|
|
@@ -74,6 +77,15 @@ export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, o
|
|
|
74
77
|
))}
|
|
75
78
|
</div>
|
|
76
79
|
)}
|
|
80
|
+
{onTalkToHuman && (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onTalkToHuman}
|
|
84
|
+
className="mt-6 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-accent)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)] rounded-md"
|
|
85
|
+
>
|
|
86
|
+
Need to talk to a human? <span className="font-medium text-[var(--color-accent)]">Start a chat →</span>
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
77
89
|
</div>
|
|
78
90
|
);
|
|
79
91
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
|
3
|
+
import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
|
|
4
4
|
import { useChat } from '@/hooks/useChat';
|
|
5
5
|
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
6
6
|
import { ChatMessage } from './ChatMessage';
|
|
7
7
|
import { ChatInput } from './ChatInput';
|
|
8
8
|
import { ChatEmptyState } from './ChatEmptyState';
|
|
9
|
+
import { crispAvailable, hideCrispLauncher, openCrispChat, showCrispLauncher } from '../../lib/crisp-bridge';
|
|
9
10
|
|
|
10
11
|
const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
|
|
11
12
|
|
|
@@ -41,6 +42,15 @@ interface ChatPanelProps {
|
|
|
41
42
|
*/
|
|
42
43
|
export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
|
|
43
44
|
const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
|
|
45
|
+
|
|
46
|
+
const [hasCrisp, setHasCrisp] = useState(false);
|
|
47
|
+
useEffect(() => { setHasCrisp(crispAvailable()); }, []);
|
|
48
|
+
|
|
49
|
+
const handleTalkToHuman = useCallback(() => {
|
|
50
|
+
onClose();
|
|
51
|
+
openCrispChat();
|
|
52
|
+
}, [onClose]);
|
|
53
|
+
|
|
44
54
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
45
55
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
46
56
|
const overlayPanelRef = useRef<HTMLDivElement>(null);
|
|
@@ -50,6 +60,17 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
50
60
|
// Lock body scroll when mobile overlay is open
|
|
51
61
|
useBodyScrollLock(!isInline && isOpen);
|
|
52
62
|
|
|
63
|
+
// Coordinate visibility with Crisp so the two chats don't overlap in the
|
|
64
|
+
// bottom-right corner. Applies to both modes — Crisp's fixed launcher sits
|
|
65
|
+
// behind the inline chat column's send button on desktop too.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!isOpen) return;
|
|
68
|
+
hideCrispLauncher();
|
|
69
|
+
return () => {
|
|
70
|
+
showCrispLauncher();
|
|
71
|
+
};
|
|
72
|
+
}, [isOpen]);
|
|
73
|
+
|
|
53
74
|
// On iOS, the virtual keyboard doesn't resize the layout viewport, so the
|
|
54
75
|
// panel's max-h-[80dvh] can extend behind the keyboard. When the user taps the
|
|
55
76
|
// textarea, iOS scrolls the page to reveal it — pushing the fixed panel off-screen.
|
|
@@ -171,6 +192,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
171
192
|
<ChatEmptyState
|
|
172
193
|
starterQuestions={starterQuestions}
|
|
173
194
|
onSelectQuestion={sendMessage}
|
|
195
|
+
onTalkToHuman={hasCrisp ? handleTalkToHuman : undefined}
|
|
174
196
|
/>
|
|
175
197
|
)}
|
|
176
198
|
</div>
|
|
@@ -181,12 +203,24 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
181
203
|
<p className="text-xs text-red-600 dark:text-red-400">
|
|
182
204
|
{error}
|
|
183
205
|
</p>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
206
|
+
<div className="flex items-center gap-3 flex-shrink-0">
|
|
207
|
+
{hasCrisp && (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={handleTalkToHuman}
|
|
211
|
+
className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline cursor-pointer"
|
|
212
|
+
>
|
|
213
|
+
Talk to a human
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={retry}
|
|
219
|
+
className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline cursor-pointer"
|
|
220
|
+
>
|
|
221
|
+
Try again
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
190
224
|
</div>
|
|
191
225
|
)}
|
|
192
226
|
|
|
@@ -220,7 +254,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
220
254
|
{/* Mobile backdrop (<lg) */}
|
|
221
255
|
{isOpen && (
|
|
222
256
|
<div
|
|
223
|
-
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[
|
|
257
|
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000003] lg:hidden"
|
|
224
258
|
onClick={onClose}
|
|
225
259
|
aria-hidden="true"
|
|
226
260
|
/>
|
|
@@ -236,7 +270,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
236
270
|
data-open={isOpen}
|
|
237
271
|
data-chat-panel
|
|
238
272
|
className={`
|
|
239
|
-
fixed z-[
|
|
273
|
+
fixed z-[1000004] flex flex-col overflow-hidden
|
|
240
274
|
transition-all duration-200
|
|
241
275
|
inset-x-2 top-4 max-h-[80dvh] rounded-2xl shadow-xl border border-[var(--color-border)]
|
|
242
276
|
lg:inset-x-auto lg:top-0 lg:max-h-none lg:rounded-none lg:shadow-none lg:border-0
|
|
@@ -267,7 +267,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
|
|
|
267
267
|
{/* Home link */}
|
|
268
268
|
<Link
|
|
269
269
|
href={homeLink}
|
|
270
|
-
prefetch={
|
|
270
|
+
prefetch={true}
|
|
271
271
|
className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors flex items-center"
|
|
272
272
|
aria-label="Home"
|
|
273
273
|
>
|
|
@@ -284,7 +284,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
|
|
|
284
284
|
) : item.path ? (
|
|
285
285
|
<Link
|
|
286
286
|
href={`${linkPrefix}/${item.path}`}
|
|
287
|
-
prefetch={
|
|
287
|
+
prefetch={true}
|
|
288
288
|
className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors"
|
|
289
289
|
>
|
|
290
290
|
{item.label}
|