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.
Files changed (35) hide show
  1. package/README.md +3 -1
  2. package/dist/lib/deps.js +3 -3
  3. package/package.json +3 -3
  4. package/vendored/app/[[...slug]]/page.tsx +36 -109
  5. package/vendored/app/api/assets/[...path]/route.ts +2 -2
  6. package/vendored/app/api/chat/[project]/route.ts +206 -138
  7. package/vendored/app/api/isr-health/route.ts +2 -3
  8. package/vendored/app/layout.tsx +2 -0
  9. package/vendored/components/JdReadySentinel.tsx +25 -0
  10. package/vendored/components/chat/ChatEmptyState.tsx +13 -1
  11. package/vendored/components/chat/ChatPanel.tsx +43 -9
  12. package/vendored/components/navigation/Breadcrumb.tsx +2 -2
  13. package/vendored/components/navigation/Header.tsx +23 -17
  14. package/vendored/components/navigation/LanguageSelector.tsx +7 -4
  15. package/vendored/components/navigation/Sidebar.tsx +28 -37
  16. package/vendored/components/navigation/TabsNav.tsx +1 -1
  17. package/vendored/hooks/useChat.ts +113 -60
  18. package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
  19. package/vendored/hooks/useTextStreamPacer.ts +152 -0
  20. package/vendored/lib/chat-prompt.ts +69 -29
  21. package/vendored/lib/chat-tools.ts +111 -0
  22. package/vendored/lib/crisp-bridge.ts +91 -0
  23. package/vendored/lib/docs-types.ts +4 -0
  24. package/vendored/lib/embedding-chunker.ts +85 -11
  25. package/vendored/lib/find-first-nav-page.ts +40 -0
  26. package/vendored/lib/hedge-strip.ts +29 -0
  27. package/vendored/lib/middleware-helpers.ts +2 -1
  28. package/vendored/lib/openapi/lang-spec-path.ts +16 -0
  29. package/vendored/lib/page-isr-helpers.ts +4 -1
  30. package/vendored/lib/public-paths-resolver.ts +3 -42
  31. package/vendored/lib/query-rewriter.ts +91 -0
  32. package/vendored/lib/ui-strings.ts +52 -0
  33. package/vendored/lib/vector-store.ts +5 -3
  34. package/vendored/schema/docs-schema.json +15 -0
  35. package/vendored/workspace-package-lock.json +88 -88
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ const DEFAULT_DELAY_MS = 500;
6
+
7
+ export interface UseDelayedNavigationSpinnerResult {
8
+ /** Path of the link that was just clicked — set immediately, used for optimistic active highlight. */
9
+ pendingPathname: string | null;
10
+ /** Path of the link that has been "pending" longer than the delay threshold — drives the spinner UI. */
11
+ spinnerPathname: string | null;
12
+ /** Call from the link's onClick handler with the link's href. */
13
+ onNavigate: (url: string) => void;
14
+ }
15
+
16
+ /**
17
+ * Two-stage navigation feedback:
18
+ * 1. Click → `pendingPathname` set immediately (optimistic active highlight).
19
+ * 2. After `delayMs` (default 500ms) → `spinnerPathname` set, signaling
20
+ * "this navigation is taking a while, show a spinner."
21
+ *
22
+ * Both clear when the live `pathname` matches the pending one (navigation
23
+ * resolved) or when the user clicks a different link (the new click cancels
24
+ * the old timer and starts fresh).
25
+ *
26
+ * Same-page hash clicks (`/current#anchor`) are ignored — they don't cause
27
+ * a real navigation, so the timer would never get cleared by `pathname` change.
28
+ *
29
+ * Double-clicks on the same pending link are no-ops — the in-flight timer keeps
30
+ * running so the spinner appears 500ms after the FIRST click, not the second.
31
+ *
32
+ * `onNavigate` has stable identity across pathname changes (live values are read
33
+ * from refs, not closure-captured) so it doesn't bust `React.memo` on the
34
+ * hundreds of `<NavPage>` instances rendered in large docs sites.
35
+ */
36
+ export function useDelayedNavigationSpinner(
37
+ pathname: string | null,
38
+ delayMs: number = DEFAULT_DELAY_MS,
39
+ ): UseDelayedNavigationSpinnerResult {
40
+ const [pendingPathname, setPendingPathname] = useState<string | null>(null);
41
+ const [spinnerPathname, setSpinnerPathname] = useState<string | null>(null);
42
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43
+ const pathnameRef = useRef(pathname);
44
+ const pendingRef = useRef<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ pathnameRef.current = pathname;
48
+ }, [pathname]);
49
+
50
+ const clearTimer = useCallback(() => {
51
+ if (timerRef.current !== null) {
52
+ clearTimeout(timerRef.current);
53
+ timerRef.current = null;
54
+ }
55
+ }, []);
56
+
57
+ const onNavigate = useCallback((url: string) => {
58
+ const path = url.split('#')[0];
59
+
60
+ // Same-page hash click — no real navigation, just clear any leftover state.
61
+ if (path === pathnameRef.current) {
62
+ clearTimer();
63
+ setPendingPathname(null);
64
+ setSpinnerPathname(null);
65
+ pendingRef.current = null;
66
+ return;
67
+ }
68
+
69
+ // Double-click on the same pending link — let the existing timer run.
70
+ if (path === pendingRef.current) {
71
+ return;
72
+ }
73
+
74
+ clearTimer();
75
+ setSpinnerPathname(null);
76
+ setPendingPathname(path);
77
+ pendingRef.current = path;
78
+ timerRef.current = setTimeout(() => {
79
+ setSpinnerPathname(path);
80
+ timerRef.current = null;
81
+ }, delayMs);
82
+ }, [clearTimer, delayMs]);
83
+
84
+ useEffect(() => {
85
+ clearTimer();
86
+ setPendingPathname(null);
87
+ setSpinnerPathname(null);
88
+ pendingRef.current = null;
89
+ }, [pathname, clearTimer]);
90
+
91
+ useEffect(() => () => clearTimer(), [clearTimer]);
92
+
93
+ return { pendingPathname, spinnerPathname, onNavigate };
94
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Rate-limited text reveal pacer for streaming chat responses.
3
+ *
4
+ * Backend SSE deltas often arrive clumped (Haiku generates short answers
5
+ * fast; CDN/middleware sometimes buffers tiny frames together). Without
6
+ * pacing, the React message state updates in one big jump and the user
7
+ * perceives "the response just appeared". This pacer holds incoming text
8
+ * in a buffer and drains it at a bounded rate via requestAnimationFrame.
9
+ *
10
+ * The `now` option exists for deterministic tests: vitest fake timers
11
+ * do NOT fake `performance.now` unless `toFake: ['performance']` is
12
+ * passed, and we want tests that don't reach into that global.
13
+ */
14
+ export interface TextStreamPacerOptions {
15
+ /** Called each frame with newly-revealed text (never the full accumulated string). */
16
+ onReveal: (chunk: string) => void;
17
+ /** Called once after `finish()` has emitted the last buffered characters,
18
+ * or synchronously after `finish()` under reduced-motion. */
19
+ onDrain?: () => void;
20
+ /** Baseline reveal rate. Default 80 chars/sec (~comfortable "writing" speed). */
21
+ baseCharsPerSecond?: number;
22
+ /** Max reveal rate while catching up. Default 400 chars/sec (~2x fast-talker read speed). */
23
+ maxCharsPerSecond?: number;
24
+ /** Buffer length at which we switch from base to max rate. Default 200. */
25
+ catchUpThreshold?: number;
26
+ /** Buffer length at which finish() dumps the whole buffer at once. Default 800.
27
+ * Only applies post-finish — ensures long responses don't take >2s of reveal
28
+ * time after the stream completes. Arriving bursts mid-stream pace normally. */
29
+ instantFlushThreshold?: number;
30
+ /** If true, pacer flushes immediately on every `enqueue` and `finish` fires
31
+ * `onDrain` synchronously. Callers own the `prefers-reduced-motion` check —
32
+ * typically via `useMediaQuery('(prefers-reduced-motion: reduce)')`. Default false. */
33
+ reducedMotion?: boolean;
34
+ /** Clock function. Default `performance.now`. Injectable for tests. */
35
+ now?: () => number;
36
+ }
37
+
38
+ export interface TextStreamPacer {
39
+ /** Add text to the buffer. RAF loop resumes on first enqueue after idle. */
40
+ enqueue(text: string): void;
41
+ /** Signal stream complete — drain remaining buffer at max rate, then fire onDrain. */
42
+ finish(): void;
43
+ /** Stop pacing immediately. Buffered text is discarded; onDrain does not fire. */
44
+ abort(): void;
45
+ }
46
+
47
+ export function createTextStreamPacer(options: TextStreamPacerOptions): TextStreamPacer {
48
+ const {
49
+ onReveal,
50
+ onDrain,
51
+ baseCharsPerSecond = 80,
52
+ maxCharsPerSecond = 400,
53
+ catchUpThreshold = 200,
54
+ instantFlushThreshold = 800,
55
+ reducedMotion = false,
56
+ now = () => performance.now(),
57
+ } = options;
58
+
59
+ let buffer = '';
60
+ let finished = false;
61
+ let aborted = false;
62
+ let rafId: number | null = null;
63
+ let lastFrameMs: number | null = null;
64
+ let fractionalChars = 0;
65
+
66
+ const flushBuffer = () => {
67
+ if (buffer.length === 0) return;
68
+ const chunk = buffer;
69
+ buffer = '';
70
+ onReveal(chunk);
71
+ };
72
+
73
+ const tick = () => {
74
+ rafId = null;
75
+ if (aborted) return;
76
+
77
+ const t = now();
78
+ const elapsedMs = lastFrameMs === null ? 16 : t - lastFrameMs;
79
+ lastFrameMs = t;
80
+
81
+ // Instant flush only applies post-completion — it bounds drain time after
82
+ // finish(), but must not short-circuit pacing while the stream is still
83
+ // arriving (otherwise a fast Haiku burst would appear in one flash).
84
+ if (finished && buffer.length >= instantFlushThreshold) {
85
+ flushBuffer();
86
+ fractionalChars = 0;
87
+ } else {
88
+ // Rate is driven purely by buffer length — don't boost on `finished`,
89
+ // or short responses whose buffer never hits catchUpThreshold would
90
+ // drain in one frame and defeat the typing-pace effect.
91
+ const rate = buffer.length > catchUpThreshold
92
+ ? maxCharsPerSecond
93
+ : baseCharsPerSecond;
94
+
95
+ fractionalChars += (rate * elapsedMs) / 1000;
96
+ const reveal = Math.min(Math.floor(fractionalChars), buffer.length);
97
+ if (reveal > 0) {
98
+ fractionalChars -= reveal;
99
+ const chunk = buffer.slice(0, reveal);
100
+ buffer = buffer.slice(reveal);
101
+ onReveal(chunk);
102
+ }
103
+ }
104
+
105
+ if (buffer.length > 0) {
106
+ schedule();
107
+ return;
108
+ }
109
+
110
+ // Drained.
111
+ lastFrameMs = null;
112
+ fractionalChars = 0;
113
+ if (finished) {
114
+ finished = false; // one-shot
115
+ onDrain?.();
116
+ }
117
+ };
118
+
119
+ const schedule = () => {
120
+ if (rafId !== null || aborted) return;
121
+ rafId = requestAnimationFrame(tick);
122
+ };
123
+
124
+ return {
125
+ enqueue(text) {
126
+ if (aborted || !text) return;
127
+ if (reducedMotion) {
128
+ onReveal(text);
129
+ return;
130
+ }
131
+ buffer += text;
132
+ schedule();
133
+ },
134
+ finish() {
135
+ if (aborted) return;
136
+ if (reducedMotion) {
137
+ onDrain?.();
138
+ return;
139
+ }
140
+ finished = true;
141
+ schedule();
142
+ },
143
+ abort() {
144
+ aborted = true;
145
+ buffer = '';
146
+ if (rafId !== null) {
147
+ cancelAnimationFrame(rafId);
148
+ rafId = null;
149
+ }
150
+ },
151
+ };
152
+ }
@@ -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,51 @@ 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
+ - Pick one of exactly two response shapes, based on whether any page in the Documentation context below is relevant to the question:
102
+ (a) RELEVANT PAGE EXISTS → answer by referring to it with a markdown link (e.g., "See [Changelog](.../changelog) for recent releases."). Do NOT prefix with "I don't have information".
103
+ (b) NO RELEVANT PAGE → respond with this exact sentence and stop: "I don't have information about that in the documentation." Do not add "However", "but", "you could try", or any follow-up suggestion after it. The reply ends at the period.
104
+ WRONG: "I don't have information about that in the documentation. However, if you're asking about X, you could..." This is forbidden. If the first sentence is true, stop there.
105
+ - Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
106
+ - 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.
107
+ - RESOLVE IMPLICIT SUBJECTS: when the user's question has no explicit subject (pronouns like "it", "this", "they", or bare noun phrases like "the product", "pricing", "the API"), assume they are asking about ${projectName} unless the conversation clearly establishes a different subject. For example, "how much does it cost" means "how much does ${projectName} cost"; "is it open source" means "is ${projectName} open source". Resolve the reference and answer from the documentation context — do not hedge just because the pronoun wasn't spelled out.
108
+ - 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.
109
+ - Use the pageSlug values (shown in the context as [pageSlug: ...]) when populating cited_page_slugs — not the page title.
66
110
 
67
- <question ending with ?>
111
+ Tool choice:
112
+ - Use the \`answer\` tool for most questions.
113
+ - 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
114
 
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?
115
+ const context = chunks.map((c) =>
116
+ `[pageSlug: ${c.pageSlug}] ${c.pageTitle} > ${c.sectionHeading}\nURL: ${baseUrl}${docsPath}/${c.pageSlug}\n${c.content}`,
117
+ ).join('\n\n---\n\n');
76
118
 
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.
119
+ const dynamicContext = `Documentation context:\n${context}`;
82
120
 
83
- Documentation context:
84
- ${context}`;
121
+ return [
122
+ { type: 'text', text: staticRules },
123
+ { type: 'text', text: dynamicContext },
124
+ ];
85
125
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tool schemas for the AI documentation chat.
3
+ *
4
+ * Forces Claude into structured output: it must call exactly one of
5
+ * `answer` (for direct answers) or `ask_clarification` (when the query
6
+ * matches 2-3 distinct topics in the retrieved context).
7
+ *
8
+ * Replaces the regex-based `extractCitations` and `extractClarificationOptions`
9
+ * parsers. Citations become the `cited_page_slugs[]` field on the answer tool;
10
+ * clarification options become the `options[]` field on the clarification tool.
11
+ */
12
+ import type Anthropic from '@anthropic-ai/sdk';
13
+
14
+ // Stricter local shapes so tests + consumers get typed access to nested
15
+ // schema fields. The SDK types `input_schema.properties` as `unknown | null`,
16
+ // which would force `any` casts at every access site. These local types are
17
+ // structurally assignable to `Anthropic.Tool`, so they remain valid SDK input.
18
+ interface AnswerToolSchema {
19
+ name: 'answer';
20
+ description: string;
21
+ input_schema: {
22
+ type: 'object';
23
+ properties: {
24
+ markdown: { type: 'string'; description: string };
25
+ cited_page_slugs: {
26
+ type: 'array';
27
+ items: { type: 'string' };
28
+ description: string;
29
+ };
30
+ };
31
+ required: ['markdown', 'cited_page_slugs'];
32
+ };
33
+ }
34
+
35
+ interface ClarificationToolSchema {
36
+ name: 'ask_clarification';
37
+ description: string;
38
+ input_schema: {
39
+ type: 'object';
40
+ properties: {
41
+ question: { type: 'string'; description: string };
42
+ options: {
43
+ type: 'array';
44
+ items: { type: 'string' };
45
+ minItems: 2;
46
+ maxItems: 3;
47
+ description: string;
48
+ };
49
+ };
50
+ required: ['question', 'options'];
51
+ };
52
+ }
53
+
54
+ export const ANSWER_TOOL: AnswerToolSchema = {
55
+ name: 'answer',
56
+ description:
57
+ 'Provide a direct answer to the user\'s question using the documentation context. ' +
58
+ 'Always populate cited_page_slugs with the pageSlug of every source you referenced. ' +
59
+ 'If the context does not contain an answer, still call this tool and say "I don\'t have information about that in the documentation." with cited_page_slugs: [].',
60
+ input_schema: {
61
+ type: 'object',
62
+ properties: {
63
+ markdown: {
64
+ type: 'string',
65
+ description:
66
+ 'The answer in markdown. Use code blocks with language hints when showing code. ' +
67
+ 'Do not embed citation text like "[Page Title]" — citations are listed in cited_page_slugs.',
68
+ },
69
+ cited_page_slugs: {
70
+ type: 'array',
71
+ items: { type: 'string' },
72
+ description:
73
+ 'The pageSlug values (e.g. "getting-started", "api/auth") for every documentation source you referenced. ' +
74
+ 'Empty array if the answer is "I don\'t have information about that".',
75
+ },
76
+ },
77
+ required: ['markdown', 'cited_page_slugs'],
78
+ },
79
+ };
80
+
81
+ export const CLARIFICATION_TOOL: ClarificationToolSchema = {
82
+ name: 'ask_clarification',
83
+ description:
84
+ 'Ask the user to choose between 2-3 distinct topics when their question is ambiguous. ' +
85
+ 'Only use when the documentation context contains multiple unrelated features that could match ' +
86
+ '(e.g. "Post Analytics" vs "Link Analytics" when asked about "analytics"). ' +
87
+ 'The question must end with a question mark.',
88
+ input_schema: {
89
+ type: 'object',
90
+ properties: {
91
+ question: {
92
+ type: 'string',
93
+ description: 'A short disambiguation question ending with "?". E.g. "Which type of analytics are you asking about?"',
94
+ },
95
+ // minItems/maxItems are advisory to the model — Anthropic does NOT
96
+ // enforce them server-side. The chat route must defensively validate
97
+ // options.length before rendering a clarification UI.
98
+ options: {
99
+ type: 'array',
100
+ items: { type: 'string' },
101
+ minItems: 2,
102
+ maxItems: 3,
103
+ description: 'The distinct topics the user can choose between. Short labels like "Post Analytics", "Link Analytics".',
104
+ },
105
+ },
106
+ required: ['question', 'options'],
107
+ },
108
+ };
109
+
110
+ // Exported as Anthropic.Tool[] so it passes directly to client.messages.stream({ tools: CHAT_TOOLS }).
111
+ export const CHAT_TOOLS: Anthropic.Tool[] = [ANSWER_TOOL, CLARIFICATION_TOOL];
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Bridge to the Crisp chat widget's command queue API.
3
+ *
4
+ * Coordinates visibility between our AI chat panel and Crisp's launcher
5
+ * so they don't visually collide in the bottom-right corner of docs sites.
6
+ *
7
+ * Crisp's docs: https://help.crisp.chat/en/article/how-to-use-crisp-from-javascript-api-1xhvaxt/
8
+ */
9
+
10
+ type CrispAction = 'chat:close' | 'chat:hide' | 'chat:show' | 'chat:open';
11
+ type CrispCommand = ['do', CrispAction];
12
+
13
+ interface CrispQueue {
14
+ push(command: CrispCommand): void;
15
+ }
16
+
17
+ function getCrisp(): CrispQueue | null {
18
+ if (typeof window === 'undefined') return null;
19
+ const q = (window as unknown as { $crisp?: unknown }).$crisp;
20
+ if (q && typeof (q as { push?: unknown }).push === 'function') {
21
+ return q as CrispQueue;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function crispAvailable(): boolean {
27
+ return getCrisp() !== null;
28
+ }
29
+
30
+ const RETRY_INTERVAL_MS = 500;
31
+ const RETRY_MAX_ATTEMPTS = 30;
32
+
33
+ let pendingHideTimer: ReturnType<typeof setInterval> | null = null;
34
+
35
+ function clearPendingHideRetry(): void {
36
+ if (pendingHideTimer !== null) {
37
+ clearInterval(pendingHideTimer);
38
+ pendingHideTimer = null;
39
+ }
40
+ }
41
+
42
+ function applyHide(crisp: CrispQueue): void {
43
+ // chat:close must come before chat:hide. Crisp can auto-reshow the
44
+ // launcher after a session reset, so closing any open session first
45
+ // reduces the race window.
46
+ crisp.push(['do', 'chat:close']);
47
+ crisp.push(['do', 'chat:hide']);
48
+ }
49
+
50
+ export function hideCrispLauncher(): void {
51
+ if (typeof window === 'undefined') return;
52
+ clearPendingHideRetry();
53
+ const crisp = getCrisp();
54
+ if (crisp) {
55
+ applyHide(crisp);
56
+ return;
57
+ }
58
+ let attempts = 0;
59
+ pendingHideTimer = setInterval(() => {
60
+ attempts += 1;
61
+ const q = getCrisp();
62
+ if (q) {
63
+ applyHide(q);
64
+ clearPendingHideRetry();
65
+ return;
66
+ }
67
+ if (attempts >= RETRY_MAX_ATTEMPTS) {
68
+ clearPendingHideRetry();
69
+ }
70
+ }, RETRY_INTERVAL_MS);
71
+ }
72
+
73
+ export function showCrispLauncher(): void {
74
+ clearPendingHideRetry();
75
+ const crisp = getCrisp();
76
+ if (!crisp) return;
77
+ crisp.push(['do', 'chat:show']);
78
+ }
79
+
80
+ export function openCrispChat(): void {
81
+ clearPendingHideRetry();
82
+ const crisp = getCrisp();
83
+ if (!crisp) return;
84
+ crisp.push(['do', 'chat:show']);
85
+ crisp.push(['do', 'chat:open']);
86
+ }
87
+
88
+ // Test-only: clears module-level retry state between cases.
89
+ export function _resetForTests(): void {
90
+ clearPendingHideRetry();
91
+ }
@@ -394,6 +394,8 @@ export interface NavigationConfig {
394
394
  */
395
395
  export interface NavbarLink {
396
396
  label: string;
397
+ /** Optional per-language overrides (e.g. `{ "fr": "Blog", "es": "Blog" }`). Falls back to `label`. */
398
+ labels?: Record<string, string>;
397
399
  icon?: IconConfig;
398
400
  href: string;
399
401
  }
@@ -404,6 +406,8 @@ export interface NavbarLink {
404
406
  export interface NavbarPrimary {
405
407
  type: 'button' | 'github';
406
408
  label?: string;
409
+ /** Optional per-language overrides. Falls back to `label` or the built-in default. */
410
+ labels?: Record<string, string>;
407
411
  href: string;
408
412
  }
409
413