jamdesk 1.1.28 → 1.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/lib/deps.js +3 -3
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +36 -109
- package/vendored/app/api/assets/[...path]/route.ts +2 -2
- package/vendored/app/api/chat/[project]/route.ts +206 -138
- package/vendored/app/api/isr-health/route.ts +2 -3
- package/vendored/app/layout.tsx +2 -0
- package/vendored/components/JdReadySentinel.tsx +25 -0
- package/vendored/components/chat/ChatEmptyState.tsx +13 -1
- package/vendored/components/chat/ChatPanel.tsx +43 -9
- package/vendored/components/navigation/Breadcrumb.tsx +2 -2
- package/vendored/components/navigation/Header.tsx +23 -17
- package/vendored/components/navigation/LanguageSelector.tsx +7 -4
- package/vendored/components/navigation/Sidebar.tsx +28 -37
- package/vendored/components/navigation/TabsNav.tsx +1 -1
- package/vendored/hooks/useChat.ts +113 -60
- package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
- package/vendored/hooks/useTextStreamPacer.ts +152 -0
- package/vendored/lib/chat-prompt.ts +69 -29
- package/vendored/lib/chat-tools.ts +111 -0
- package/vendored/lib/crisp-bridge.ts +91 -0
- package/vendored/lib/docs-types.ts +4 -0
- package/vendored/lib/embedding-chunker.ts +85 -11
- package/vendored/lib/find-first-nav-page.ts +40 -0
- package/vendored/lib/hedge-strip.ts +29 -0
- package/vendored/lib/middleware-helpers.ts +2 -1
- package/vendored/lib/openapi/lang-spec-path.ts +16 -0
- package/vendored/lib/page-isr-helpers.ts +4 -1
- package/vendored/lib/public-paths-resolver.ts +3 -42
- package/vendored/lib/query-rewriter.ts +91 -0
- package/vendored/lib/ui-strings.ts +52 -0
- package/vendored/lib/vector-store.ts +5 -3
- package/vendored/schema/docs-schema.json +15 -0
- package/vendored/workspace-package-lock.json +88 -88
|
@@ -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(/'|'/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,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
|
|
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
|
+
- 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
|
-
|
|
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
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|