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 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-light.png" width="280" alt="Jamdesk" />
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.28",
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, buildCannedIdentityReply, resolveSiteName } from '@/lib/chat-prompt';
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
- let chunks: Awaited<ReturnType<typeof querySimilarChunks>>;
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
- chunks = await querySimilarChunks(project, searchQuery, 8);
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 fullText = '';
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 === 'content_block_delta' && event.delta.type === 'text_delta') {
278
- fullText += event.delta.text;
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
- // Extract clarification options (if Claude asked a disambiguation question)
289
- const options = extractClarificationOptions(fullText);
290
- if (options) {
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
- // Extract [Title] references from Claude's response (skip markdown links [text](url))
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: isIdentityReply || hadExplicitCitations,
316
- hasClarification: options !== null,
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
- <button
185
- onClick={retry}
186
- className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline flex-shrink-0 cursor-pointer"
187
- >
188
- Try again
189
- </button>
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-[54] lg:hidden"
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-[55] flex flex-col overflow-hidden
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(/&apos;|&#39;/g, "'")
54
+ .replace(/&quot;|&#34;/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 context = chunks.map((c, i) =>
51
- `[Source ${i + 1}: ${c.pageTitle} > ${c.sectionHeading}](${baseUrl}${docsPath}/${c.pageSlug})\n${c.content}`
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
- - Every response must start with the substantive answer. If the context addresses the question — including with "no", "not supported", or "you must do X yourself" — that IS the answer; state it directly and cite the source. Never open with a disclaimer like "I don't have information…", "The documentation doesn't cover that", or "According to the documentation".
59
- - If the context contains no information relevant to the topic, start with a brief lead-in like "${projectName} doesn't document that directly — here's the closest I found:" and describe what's available. Do not invent details.
60
- - Be concise and direct
61
- - Use markdown formatting, including code blocks with language hints when showing code
62
- - Cite sources by referencing the page title in brackets, e.g. [Getting Started]
63
- - Never make up information not in the context
64
- - 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
65
- - DISAMBIGUATION (highest priority, overrides the "start with the substantive answer" rule): 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:
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
- <question ending with ?>
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
- 1. <option>
70
- 2. <option>
71
- 3. <option>
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
- 1. Post Analytics
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
- Documentation context:
84
- ${context}`;
119
+ return [
120
+ { type: 'text', text: staticRules },
121
+ { type: 'text', text: dynamicContext },
122
+ ];
85
123
  }