jamdesk 1.1.28 → 1.1.29
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/package.json +1 -1
- package/vendored/app/api/chat/[project]/route.ts +160 -133
- package/vendored/components/chat/ChatEmptyState.tsx +13 -1
- package/vendored/components/chat/ChatPanel.tsx +43 -9
- package/vendored/lib/chat-prompt.ts +67 -29
- package/vendored/lib/chat-tools.ts +111 -0
- package/vendored/lib/crisp-bridge.ts +91 -0
- package/vendored/lib/embedding-chunker.ts +85 -11
- package/vendored/lib/query-rewriter.ts +91 -0
- package/vendored/lib/vector-store.ts +5 -3
- package/vendored/workspace-package-lock.json +73 -73
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<a href="https://www.jamdesk.com">
|
|
3
|
-
<img src="https://www.jamdesk.com/logo-
|
|
3
|
+
<img src="https://www.jamdesk.com/logo-universal.png" width="280" alt="Jamdesk" />
|
|
4
4
|
</a>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
@@ -435,6 +435,7 @@ See the [docs.json reference](https://www.jamdesk.com/docs/config/docs-json-refe
|
|
|
435
435
|
|
|
436
436
|
- Node.js v20.0.0+
|
|
437
437
|
- npm v8+ (recommended)
|
|
438
|
+
- macOS, Linux, or Windows (all tested in CI)
|
|
438
439
|
|
|
439
440
|
## Learn More
|
|
440
441
|
|
|
@@ -446,6 +447,7 @@ See the [docs.json reference](https://www.jamdesk.com/docs/config/docs-json-refe
|
|
|
446
447
|
- [OpenAPI](https://www.jamdesk.com/docs/api-reference/openapi-setup)
|
|
447
448
|
- [Deployment](https://www.jamdesk.com/docs/deploy)
|
|
448
449
|
- [npm Package](https://www.npmjs.com/package/jamdesk)
|
|
450
|
+
- [Release History](https://www.npmjs.com/package/jamdesk?activeTab=versions)
|
|
449
451
|
- [Homepage](https://www.jamdesk.com)
|
|
450
452
|
- [Pricing](https://www.jamdesk.com/pricing)
|
|
451
453
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.29",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -22,11 +22,14 @@
|
|
|
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
26
|
import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers';
|
|
27
27
|
import { getAnthropicClient } from '@/lib/anthropic-client';
|
|
28
|
+
import { rewriteQueryForSearch } from '@/lib/query-rewriter';
|
|
28
29
|
import { fetchDocsConfig } from '@/lib/r2-content';
|
|
29
30
|
import { redis } from '@/lib/redis';
|
|
31
|
+
import { CHAT_TOOLS } from '@/lib/chat-tools';
|
|
32
|
+
import { log } from '@/lib/logger';
|
|
30
33
|
|
|
31
34
|
export const runtime = 'nodejs';
|
|
32
35
|
export const maxDuration = 30;
|
|
@@ -37,113 +40,6 @@ const RATE_WINDOW_SECONDS = 60;
|
|
|
37
40
|
const MAX_HISTORY = 10;
|
|
38
41
|
const VALID_ROLES = new Set<string>(['user', 'assistant']);
|
|
39
42
|
|
|
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
43
|
export async function POST(
|
|
148
44
|
request: NextRequest,
|
|
149
45
|
context: { params: Promise<{ project: string }> },
|
|
@@ -221,9 +117,34 @@ export async function POST(
|
|
|
221
117
|
fetchDocsConfig(project).catch(() => null),
|
|
222
118
|
]);
|
|
223
119
|
|
|
224
|
-
|
|
120
|
+
// Fully parallel retrieval:
|
|
121
|
+
// - Original vector query runs immediately.
|
|
122
|
+
// - Rewriter + rewritten-query vector search are chained into ONE promise,
|
|
123
|
+
// then Promise.all'd with the original. Critical path is
|
|
124
|
+
// max(original_query_time, rewriter_time + rewritten_query_time), not
|
|
125
|
+
// max(original, rewriter) + rewritten_query_time.
|
|
126
|
+
// Any failure in the rewrite path resolves to an empty chunk list — the
|
|
127
|
+
// original query still returns results so chat doesn't fail.
|
|
128
|
+
const originalQueryPromise = querySimilarChunks(project, searchQuery, 15);
|
|
129
|
+
|
|
130
|
+
const rewrittenPathPromise: Promise<Awaited<ReturnType<typeof querySimilarChunks>>> =
|
|
131
|
+
rewriteQueryForSearch(message, history)
|
|
132
|
+
.catch(() => null)
|
|
133
|
+
.then(rewritten => {
|
|
134
|
+
// Compare against `message` (the rewriter's input), not `searchQuery`
|
|
135
|
+
// (which is enriched with prior-turn context for short follow-ups).
|
|
136
|
+
// The rewriter only sees `message`, so a no-op rewrite equals `message`.
|
|
137
|
+
if (!rewritten || rewritten === message) return [];
|
|
138
|
+
return querySimilarChunks(project, rewritten, 15).catch(() => []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
142
|
+
let rewrittenChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
225
143
|
try {
|
|
226
|
-
|
|
144
|
+
[originalChunks, rewrittenChunks] = await Promise.all([
|
|
145
|
+
originalQueryPromise,
|
|
146
|
+
rewrittenPathPromise,
|
|
147
|
+
]);
|
|
227
148
|
} catch {
|
|
228
149
|
return Response.json(
|
|
229
150
|
{ error: 'AI chat is not available for this project.' },
|
|
@@ -231,6 +152,21 @@ export async function POST(
|
|
|
231
152
|
);
|
|
232
153
|
}
|
|
233
154
|
|
|
155
|
+
// Merge: original chunks first (they match the user's literal phrasing),
|
|
156
|
+
// then unique chunks from the rewritten query. Dedup by pageSlug +
|
|
157
|
+
// sectionHeading — chunks with the same identity are interchangeable across
|
|
158
|
+
// queries.
|
|
159
|
+
const seenKeys = new Set(originalChunks.map(c => `${c.pageSlug}#${c.sectionHeading}`));
|
|
160
|
+
const chunks = [
|
|
161
|
+
...originalChunks,
|
|
162
|
+
...rewrittenChunks.filter(c => {
|
|
163
|
+
const key = `${c.pageSlug}#${c.sectionHeading}`;
|
|
164
|
+
if (seenKeys.has(key)) return false;
|
|
165
|
+
seenKeys.add(key);
|
|
166
|
+
return true;
|
|
167
|
+
}),
|
|
168
|
+
];
|
|
169
|
+
|
|
234
170
|
if (chunks.length === 0) {
|
|
235
171
|
return Response.json(
|
|
236
172
|
{ error: 'No documentation content found for this project.' },
|
|
@@ -257,6 +193,8 @@ export async function POST(
|
|
|
257
193
|
max_tokens: 2048,
|
|
258
194
|
temperature: 0.3,
|
|
259
195
|
system: systemPrompt,
|
|
196
|
+
tools: CHAT_TOOLS,
|
|
197
|
+
tool_choice: { type: 'any' },
|
|
260
198
|
messages: [
|
|
261
199
|
...history.slice(-MAX_HISTORY),
|
|
262
200
|
{ role: 'user', content: message },
|
|
@@ -268,43 +206,127 @@ export async function POST(
|
|
|
268
206
|
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
|
269
207
|
}
|
|
270
208
|
|
|
209
|
+
type Citation = { title: string; slug: string; section?: string };
|
|
210
|
+
type AnswerInput = { markdown?: string; cited_page_slugs?: string[] };
|
|
211
|
+
type ClarificationInput = { question?: string; options?: string[] };
|
|
212
|
+
|
|
213
|
+
function topChunksAsCitations(limit = 2, skip?: Set<string>): Citation[] {
|
|
214
|
+
const out: Citation[] = [];
|
|
215
|
+
const seen = new Set<string>(skip);
|
|
216
|
+
for (const c of chunks) {
|
|
217
|
+
if (seen.has(c.pageSlug)) continue;
|
|
218
|
+
seen.add(c.pageSlug);
|
|
219
|
+
out.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
|
|
220
|
+
if (out.length >= limit) break;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
271
225
|
const readable = new ReadableStream({
|
|
272
226
|
async start(controller) {
|
|
273
|
-
let
|
|
227
|
+
let toolName: string | null = null;
|
|
228
|
+
let emittedMarkdownLength = 0;
|
|
229
|
+
let emittedQuestionLength = 0;
|
|
230
|
+
|
|
231
|
+
stream.on('inputJson', (_partialJson, jsonSnapshot) => {
|
|
232
|
+
if (!jsonSnapshot || typeof jsonSnapshot !== 'object') return;
|
|
233
|
+
|
|
234
|
+
// Gate on toolName — emitting before content_block_start resolves
|
|
235
|
+
// would leak clarification JSON into the answer text channel.
|
|
236
|
+
if (toolName === 'answer') {
|
|
237
|
+
const snap = jsonSnapshot as AnswerInput;
|
|
238
|
+
if (typeof snap.markdown === 'string' && snap.markdown.length > emittedMarkdownLength) {
|
|
239
|
+
const newContent = snap.markdown.slice(emittedMarkdownLength);
|
|
240
|
+
emittedMarkdownLength = snap.markdown.length;
|
|
241
|
+
controller.enqueue(sendEvent({ type: 'text', content: newContent }));
|
|
242
|
+
}
|
|
243
|
+
} else if (toolName === 'ask_clarification') {
|
|
244
|
+
const snap = jsonSnapshot as ClarificationInput;
|
|
245
|
+
if (typeof snap.question === 'string' && snap.question.length > emittedQuestionLength) {
|
|
246
|
+
const newContent = snap.question.slice(emittedQuestionLength);
|
|
247
|
+
emittedQuestionLength = snap.question.length;
|
|
248
|
+
controller.enqueue(sendEvent({ type: 'text', content: newContent }));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
274
252
|
|
|
275
253
|
try {
|
|
254
|
+
// Watch for content_block_start to learn which tool Claude picked.
|
|
276
255
|
for await (const event of stream) {
|
|
277
|
-
if (event.type === '
|
|
278
|
-
|
|
279
|
-
controller.enqueue(sendEvent({ type: 'text', content: event.delta.text }));
|
|
256
|
+
if (event.type === 'content_block_start' && event.content_block.type === 'tool_use') {
|
|
257
|
+
toolName = event.content_block.name;
|
|
280
258
|
}
|
|
281
259
|
}
|
|
282
260
|
|
|
283
|
-
// After streaming completes, get token usage
|
|
284
261
|
const finalMessage = await stream.finalMessage();
|
|
285
262
|
const inputTokens = finalMessage.usage?.input_tokens ?? 0;
|
|
286
263
|
const outputTokens = finalMessage.usage?.output_tokens ?? 0;
|
|
287
264
|
|
|
288
|
-
//
|
|
289
|
-
const
|
|
290
|
-
if (
|
|
265
|
+
// With tool_choice: 'any' there is exactly one tool_use block.
|
|
266
|
+
const toolUse = finalMessage.content.find(c => c.type === 'tool_use');
|
|
267
|
+
if (!toolUse || toolUse.type !== 'tool_use') {
|
|
268
|
+
throw new Error('Expected tool_use block in final message');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let sources: Citation[];
|
|
272
|
+
let hadExplicitCitations: boolean;
|
|
273
|
+
const hasClarification = toolUse.name === 'ask_clarification';
|
|
274
|
+
|
|
275
|
+
if (toolUse.name === 'answer') {
|
|
276
|
+
const input = toolUse.input as AnswerInput;
|
|
277
|
+
const slugs = Array.isArray(input.cited_page_slugs) ? input.cited_page_slugs : [];
|
|
278
|
+
const markdownText = typeof input.markdown === 'string' ? input.markdown : '';
|
|
279
|
+
// Suppress citations on the canned identity reply — otherwise the
|
|
280
|
+
// top-2 fallback would attach unrelated docs under "I'm the
|
|
281
|
+
// documentation assistant for …".
|
|
282
|
+
const isIdentityReply = isCannedIdentityReply(markdownText, siteName);
|
|
283
|
+
|
|
284
|
+
const seen = new Set<string>();
|
|
285
|
+
const explicitSources: Citation[] = [];
|
|
286
|
+
if (!isIdentityReply) {
|
|
287
|
+
for (const slug of slugs) {
|
|
288
|
+
const chunk = chunks.find(c => c.pageSlug === slug);
|
|
289
|
+
if (chunk && !seen.has(chunk.pageSlug)) {
|
|
290
|
+
seen.add(chunk.pageSlug);
|
|
291
|
+
explicitSources.push({
|
|
292
|
+
title: chunk.pageTitle,
|
|
293
|
+
slug: chunk.pageSlug,
|
|
294
|
+
section: chunk.sectionHeading,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Identity replies set hadExplicitCitations=true to mark intentional
|
|
300
|
+
// no-cite; non-identity answers fall back to top-2 when Claude omits slugs.
|
|
301
|
+
hadExplicitCitations = isIdentityReply || explicitSources.length > 0;
|
|
302
|
+
sources = isIdentityReply
|
|
303
|
+
? []
|
|
304
|
+
: explicitSources.length > 0
|
|
305
|
+
? explicitSources
|
|
306
|
+
: topChunksAsCitations(2, seen);
|
|
307
|
+
} else if (toolUse.name === 'ask_clarification') {
|
|
308
|
+
const input = toolUse.input as ClarificationInput;
|
|
309
|
+
const options = Array.isArray(input.options) ? input.options : [];
|
|
310
|
+
const question = typeof input.question === 'string' ? input.question.trim() : '';
|
|
311
|
+
// Missing question → UI shows buttons with no prompt.
|
|
312
|
+
// <2 options → dead-end UI. Interrupt so the user can retry.
|
|
313
|
+
if (!question) {
|
|
314
|
+
throw new Error('ask_clarification returned no question');
|
|
315
|
+
}
|
|
316
|
+
if (options.length < 2) {
|
|
317
|
+
throw new Error(`ask_clarification returned ${options.length} options; minimum is 2`);
|
|
318
|
+
}
|
|
291
319
|
controller.enqueue(sendEvent({ type: 'clarification', options }));
|
|
320
|
+
// Surface top chunks so users see where the disambiguation candidates come from.
|
|
321
|
+
sources = topChunksAsCitations();
|
|
322
|
+
hadExplicitCitations = false;
|
|
323
|
+
} else {
|
|
324
|
+
throw new Error(`Unexpected tool name: ${toolUse.name}`);
|
|
292
325
|
}
|
|
293
326
|
|
|
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
|
-
}));
|
|
327
|
+
controller.enqueue(sendEvent({ type: 'citations', sources }));
|
|
305
328
|
controller.enqueue(sendEvent({ type: 'done' }));
|
|
306
329
|
|
|
307
|
-
// Fire-and-forget analytics — only on success (finalMessage unreliable on error)
|
|
308
330
|
trackChatAnalytics({
|
|
309
331
|
projectSlug: project,
|
|
310
332
|
query: message,
|
|
@@ -312,12 +334,17 @@ export async function POST(
|
|
|
312
334
|
inputTokens,
|
|
313
335
|
outputTokens,
|
|
314
336
|
model: CHAT_MODEL,
|
|
315
|
-
hadExplicitCitations
|
|
316
|
-
hasClarification
|
|
337
|
+
hadExplicitCitations,
|
|
338
|
+
hasClarification,
|
|
317
339
|
durationMs: Date.now() - startTime,
|
|
318
340
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
319
341
|
}).catch(() => {});
|
|
320
|
-
} catch {
|
|
342
|
+
} catch (err) {
|
|
343
|
+
log('warn', 'chat: stream interrupted', {
|
|
344
|
+
projectSlug: project,
|
|
345
|
+
toolName,
|
|
346
|
+
error: err instanceof Error ? err.message : String(err),
|
|
347
|
+
});
|
|
321
348
|
controller.enqueue(sendEvent({ type: 'error', message: 'The response was interrupted. Please try again.' }));
|
|
322
349
|
}
|
|
323
350
|
controller.close();
|
|
@@ -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
|
|
@@ -29,6 +29,40 @@ export function buildCannedIdentityReply(projectName: string): string {
|
|
|
29
29
|
return `I'm the documentation assistant for ${projectName}, here to help with questions about the product.`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Detects whether Claude's markdown output is the canned identity reply.
|
|
34
|
+
*
|
|
35
|
+
* Haiku at temperature 0.3 drifts the exact string in small ways: curly vs
|
|
36
|
+
* straight apostrophes, trailing period variants, surrounding markdown
|
|
37
|
+
* emphasis (`*...*`), or extra whitespace. An exact === compare against
|
|
38
|
+
* buildCannedIdentityReply would miss those and let the "top 2 by score"
|
|
39
|
+
* citation fallback attach unrelated docs sources under the identity reply.
|
|
40
|
+
*
|
|
41
|
+
* We normalize both sides (strip markdown emphasis/quotes, collapse
|
|
42
|
+
* whitespace, fold apostrophes, drop trailing punctuation, lowercase) and
|
|
43
|
+
* compare on the stable prefix "I'm the documentation assistant for <name>".
|
|
44
|
+
* A suffix check keeps the match anchored so arbitrary Haiku output that
|
|
45
|
+
* happens to start with that prefix while discussing something else doesn't
|
|
46
|
+
* slip through.
|
|
47
|
+
*/
|
|
48
|
+
export function isCannedIdentityReply(markdown: string, projectName: string): boolean {
|
|
49
|
+
if (!projectName) return false;
|
|
50
|
+
const canned = buildCannedIdentityReply(projectName);
|
|
51
|
+
const normalize = (s: string) =>
|
|
52
|
+
s
|
|
53
|
+
.replace(/'|'/g, "'")
|
|
54
|
+
.replace(/"|"/g, '"')
|
|
55
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
56
|
+
.replace(/[\u201C\u201D]/g, '"')
|
|
57
|
+
.replace(/[*_`>]/g, '')
|
|
58
|
+
.replace(/(?:^|\s)-{3,}(?:\s|$)/g, ' ')
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/[.!?]+$/, '')
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
return normalize(markdown) === normalize(canned);
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
/**
|
|
33
67
|
* Produce the display name for the chat assistant's system prompt from a
|
|
34
68
|
* docs.json config. Strips newlines and caps length to defend against
|
|
@@ -41,45 +75,49 @@ export function resolveSiteName(config: { name?: unknown } | null | undefined):
|
|
|
41
75
|
return cleaned || DEFAULT_SITE_NAME;
|
|
42
76
|
}
|
|
43
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Returns the system prompt as an array of text blocks. The first block
|
|
80
|
+
* contains ONLY the static instructions (identical for every request on the
|
|
81
|
+
* same project). The second block contains the dynamic, per-query context.
|
|
82
|
+
*
|
|
83
|
+
* Splitting by staticness means future prompt caching (out of scope for this
|
|
84
|
+
* plan) can be enabled by adding `cache_control: { type: 'ephemeral' }` to
|
|
85
|
+
* the first block only — a one-line change.
|
|
86
|
+
*/
|
|
44
87
|
export function buildSystemPrompt(
|
|
45
88
|
projectName: string,
|
|
46
89
|
chunks: ChatContext[],
|
|
47
90
|
baseUrl: string,
|
|
48
91
|
docsPath: string,
|
|
49
|
-
): string {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
).join('\n\n---\n\n');
|
|
53
|
-
|
|
54
|
-
return `You are a documentation assistant for ${projectName}. Answer the user's question using only the documentation context below.
|
|
92
|
+
): Array<{ type: 'text'; text: string }> {
|
|
93
|
+
const staticRules = `You are a helpful documentation assistant for ${projectName}.
|
|
94
|
+
Answer questions using ONLY the documentation context provided. If the answer is not in the context, call the \`answer\` tool with "I don't have information about that in the documentation." and cited_page_slugs: [].
|
|
55
95
|
|
|
56
96
|
Rules:
|
|
57
97
|
- IDENTITY (highest priority, overrides all other rules): If the user asks what you are, who made you, which model or AI powers you, or any variation (examples: "what model are you", "which AI is this", "are you Claude", "are you GPT", "who built you", "what are you running on"), respond with ONLY this exact sentence and nothing else: "I'm the documentation assistant for ${projectName}, here to help with questions about the product." Never mention Claude, Anthropic, OpenAI, GPT, Haiku, Sonnet, Opus, or any model or provider name when describing yourself or what powers you. You MAY mention these names when they appear in the documentation context and the user is asking about them as a subject (e.g., "how do I call the Claude API?" when Claude is documented) — in that case, answer normally using the context. Do not confirm or deny specific technologies about yourself. If the user presses or rephrases an identity question, repeat the canned sentence verbatim. If the user asks a compound question that mixes identity with a real doc question (e.g., "what model are you and how do I install?"), answer with ONLY the canned sentence — the identity clause wins.
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
98
|
+
- Be concise and direct.
|
|
99
|
+
- Use markdown formatting, including code blocks with language hints when showing code.
|
|
100
|
+
- Never make up information not in the context.
|
|
101
|
+
- Never combine "I don't have information" with a follow-up suggestion in the same response — that creates a self-contradiction. Pick one of exactly two response shapes:
|
|
102
|
+
(a) If any page in the Documentation context is relevant to the question, answer by referring to that page with a link (e.g., "See [Changelog](.../changelog) for recent releases."). Do not prefix with "I don't have information".
|
|
103
|
+
(b) If no relevant page is in context, respond with ONLY "I don't have information about that in the documentation." — no "but check", no "you can find", no alternatives.
|
|
104
|
+
- Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
|
|
105
|
+
- Refer to this site as "${projectName}" or just "the documentation". Never expose internal project slugs, repo names, or hostnames (e.g. do not say "jamdesk-docs", "acme-docs", or "<slug>.jamdesk.app") even if they appear in source URLs.
|
|
106
|
+
- 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. If the context has no code-relevant information, skip the example.
|
|
107
|
+
- Use the pageSlug values (shown in the context as [pageSlug: ...]) when populating cited_page_slugs — not the page title.
|
|
66
108
|
|
|
67
|
-
|
|
109
|
+
Tool choice:
|
|
110
|
+
- Use the \`answer\` tool for most questions.
|
|
111
|
+
- Use the \`ask_clarification\` tool ONLY when the context contains 2-3 distinct, unrelated features that could match the user's question (e.g. "Post Analytics" vs "Link Analytics" when asked about "analytics"). If you are sure which one the user means, answer directly.`;
|
|
68
112
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Rules: question mark required, numbered list required (1. 2. 3.), no descriptions after options, no extra text before or after. Example:
|
|
74
|
-
|
|
75
|
-
Which type of analytics are you interested in?
|
|
113
|
+
const context = chunks.map((c) =>
|
|
114
|
+
`[pageSlug: ${c.pageSlug}] ${c.pageTitle} > ${c.sectionHeading}\nURL: ${baseUrl}${docsPath}/${c.pageSlug}\n${c.content}`,
|
|
115
|
+
).join('\n\n---\n\n');
|
|
76
116
|
|
|
77
|
-
|
|
78
|
-
2. Social Analytics
|
|
79
|
-
3. Link Analytics
|
|
80
|
-
- 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.
|
|
81
|
-
- 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.
|
|
117
|
+
const dynamicContext = `Documentation context:\n${context}`;
|
|
82
118
|
|
|
83
|
-
|
|
84
|
-
|
|
119
|
+
return [
|
|
120
|
+
{ type: 'text', text: staticRules },
|
|
121
|
+
{ type: 'text', text: dynamicContext },
|
|
122
|
+
];
|
|
85
123
|
}
|