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
|
@@ -14,8 +14,15 @@ export interface EmbeddingChunk {
|
|
|
14
14
|
pageSlug: string;
|
|
15
15
|
/** Heading of the section this chunk belongs to */
|
|
16
16
|
sectionHeading: string;
|
|
17
|
-
/** Plain
|
|
17
|
+
/** Plain-text body, stripped of markdown and JSX. Used for display (LLM context, search snippets). */
|
|
18
18
|
content: string;
|
|
19
|
+
/**
|
|
20
|
+
* `<pageTitle> > <sectionHeading>\n` breadcrumb (plus `API Reference — METHOD /path\n`
|
|
21
|
+
* for API pages) that gets prepended to `content` when embedding — so BM25 finds
|
|
22
|
+
* chunks by page-title terms even when the body never uses them. Kept as a
|
|
23
|
+
* separate field so snippet consumers don't have to strip it.
|
|
24
|
+
*/
|
|
25
|
+
prefix: string;
|
|
19
26
|
/** Page title from frontmatter, or slug-derived fallback */
|
|
20
27
|
pageTitle: string;
|
|
21
28
|
}
|
|
@@ -111,18 +118,82 @@ function titleFromSlug(slug: string): string {
|
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
121
|
+
* Build the per-chunk prefix that gets prepended to the cleaned content
|
|
122
|
+
* before embedding/upsert. Two purposes:
|
|
123
|
+
*
|
|
124
|
+
* 1. Title-breadcrumb: `<pageTitle> > <sectionHeading>` gives every chunk
|
|
125
|
+
* a literal occurrence of its page title in the indexed text — so BM25
|
|
126
|
+
* finds e.g. the `Changelog > April 2026` chunk for a "changelog" query
|
|
127
|
+
* even when the month's content (Password Protection, YouTube Shorts)
|
|
128
|
+
* never mentions the word "changelog" itself. Without this, pages that
|
|
129
|
+
* happen to discuss a concept frequently (like `components/update`
|
|
130
|
+
* documenting the `<Update>` MDX tag) outrank the actual answer chunks.
|
|
131
|
+
*
|
|
132
|
+
* 2. API-method tag: API-reference pages additionally get an
|
|
133
|
+
* `API Reference — POST /endpoint` line so HTTP-method-specific queries
|
|
134
|
+
* ("how do I POST to /analytics") cluster to the right endpoint page.
|
|
135
|
+
*
|
|
136
|
+
* Both lines end in `\n` so they stay visually separated from the content
|
|
137
|
+
* body when the chat model reads the full context block.
|
|
117
138
|
*/
|
|
118
139
|
function getEmbeddingPrefix(
|
|
140
|
+
pageTitle: string,
|
|
141
|
+
sectionHeading: string,
|
|
119
142
|
slug: string,
|
|
120
143
|
frontmatter: Record<string, unknown>,
|
|
121
144
|
): string {
|
|
122
145
|
const apiMethod = (frontmatter.api as string) || (frontmatter.openapi as string);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
const apiLabel = apiMethod
|
|
147
|
+
? `API Reference — ${apiMethod}\n`
|
|
148
|
+
: slug.startsWith('apis/') ? 'API Reference\n' : '';
|
|
149
|
+
const titleLabel = sectionHeading ? `${pageTitle} > ${sectionHeading}` : pageTitle;
|
|
150
|
+
return `${apiLabel}${titleLabel}\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Without this, changelog-style pages that use `<Update>` wrappers (a
|
|
155
|
+
* Mintlify convention) collapse into a single heading-less blob — features
|
|
156
|
+
* in April get merged with features in March, and retrieval can't target
|
|
157
|
+
* a specific release. Promoting each label to a synthetic `### <label>`
|
|
158
|
+
* heading lets the existing heading splitter separate them.
|
|
159
|
+
*
|
|
160
|
+
* Matches `label` regardless of attribute order, handles self-closing tags,
|
|
161
|
+
* and leaves Update tags without a `label` attribute alone for
|
|
162
|
+
* `stripForEmbedding` to handle.
|
|
163
|
+
*
|
|
164
|
+
* Fenced code blocks are masked so Update tags inside ``` ... ``` examples
|
|
165
|
+
* (e.g. `components/update.mdx` pages that document the component itself)
|
|
166
|
+
* don't produce spurious chunks. `[^><]*?` (not `[^>]*?`) prevents a
|
|
167
|
+
* malformed opener (missing `>`) from greedily matching through to the
|
|
168
|
+
* next `</Update>` and silently eating body text.
|
|
169
|
+
*/
|
|
170
|
+
function preprocessUpdateBlocks(content: string): string {
|
|
171
|
+
const preserved: string[] = [];
|
|
172
|
+
const masked = content.replace(/```[\s\S]*?```/g, (m) => {
|
|
173
|
+
preserved.push(m);
|
|
174
|
+
return `\x00${preserved.length - 1}\x00`;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const transformed = masked
|
|
178
|
+
.replace(
|
|
179
|
+
/<Update\b[^><]*?\blabel="([^"]+)"[^><]*?\/?>/g,
|
|
180
|
+
(_, label: string) => {
|
|
181
|
+
const clean = sanitizeHeadingText(label);
|
|
182
|
+
return clean ? `\n### ${clean}\n` : '\n';
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
.replace(/<\/Update>/g, '\n');
|
|
186
|
+
|
|
187
|
+
return transformed.replace(/\x00(\d+)\x00/g, (_, i) => preserved[parseInt(i)]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Collapse whitespace and drop leading `#` runs so a synthetic heading
|
|
192
|
+
* derived from a label (or a page title flowing into the breadcrumb) can't
|
|
193
|
+
* inject extra markdown heading depth or span multiple lines.
|
|
194
|
+
*/
|
|
195
|
+
function sanitizeHeadingText(raw: string): string {
|
|
196
|
+
return raw.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim();
|
|
126
197
|
}
|
|
127
198
|
|
|
128
199
|
/**
|
|
@@ -137,11 +208,11 @@ export function chunkPageForEmbedding(
|
|
|
137
208
|
maxChars = 2000,
|
|
138
209
|
): EmbeddingChunk[] {
|
|
139
210
|
const slug = page.path.replace(/\.mdx?$/, '').replace(/\\/g, '/');
|
|
140
|
-
const
|
|
141
|
-
const
|
|
211
|
+
const rawTitle = (page.frontmatter.title as string) || titleFromSlug(slug);
|
|
212
|
+
const pageTitle = sanitizeHeadingText(rawTitle) || titleFromSlug(slug);
|
|
142
213
|
|
|
143
214
|
// Normalize Windows line endings before extracting sections
|
|
144
|
-
const normalizedContent = page.content.replace(/\r\n/g, '\n');
|
|
215
|
+
const normalizedContent = preprocessUpdateBlocks(page.content.replace(/\r\n/g, '\n'));
|
|
145
216
|
|
|
146
217
|
const sections = extractSections(normalizedContent);
|
|
147
218
|
const chunks: EmbeddingChunk[] = [];
|
|
@@ -156,13 +227,16 @@ export function chunkPageForEmbedding(
|
|
|
156
227
|
? [cleanContent]
|
|
157
228
|
: splitAtSentenceBoundaries(cleanContent, maxChars);
|
|
158
229
|
|
|
230
|
+
const prefix = getEmbeddingPrefix(pageTitle, section.heading, slug, page.frontmatter);
|
|
231
|
+
|
|
159
232
|
for (const piece of pieces) {
|
|
160
233
|
if (!piece.trim()) continue;
|
|
161
234
|
chunks.push({
|
|
162
235
|
id: `${slug}#${chunkIndex}`,
|
|
163
236
|
pageSlug: slug,
|
|
164
237
|
sectionHeading: section.heading,
|
|
165
|
-
content:
|
|
238
|
+
content: piece,
|
|
239
|
+
prefix,
|
|
166
240
|
pageTitle,
|
|
167
241
|
});
|
|
168
242
|
chunkIndex++;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk the navigation tree and return the slug of the first leaf page.
|
|
3
|
+
* Used to resolve empty-root and bare-language URLs (`/`, `/fr`, `/es`) to
|
|
4
|
+
* a concrete page in place, without emitting a redirect. Returns null if no
|
|
5
|
+
* leaf page is found.
|
|
6
|
+
*/
|
|
7
|
+
export function findFirstNavPage(nav: unknown): string | null {
|
|
8
|
+
if (!nav || typeof nav !== 'object') return null;
|
|
9
|
+
const node = nav as Record<string, unknown>;
|
|
10
|
+
const pages = node.pages;
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(pages)) {
|
|
13
|
+
for (const p of pages) {
|
|
14
|
+
if (typeof p === 'string' && p.length > 0) return '/' + p;
|
|
15
|
+
if (p && typeof p === 'object') {
|
|
16
|
+
const pageVal = (p as Record<string, unknown>).page;
|
|
17
|
+
if (typeof pageVal === 'string' && pageVal.length > 0) {
|
|
18
|
+
return '/' + pageVal;
|
|
19
|
+
}
|
|
20
|
+
const nested = findFirstNavPage(p);
|
|
21
|
+
if (nested) return nested;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// A group is an atomic container — exhaust its pages rather than
|
|
25
|
+
// falling through to the sibling-key loop below.
|
|
26
|
+
if ('group' in node) return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
|
|
30
|
+
const arr = node[key];
|
|
31
|
+
if (Array.isArray(arr)) {
|
|
32
|
+
for (const item of arr) {
|
|
33
|
+
const nested = findFirstNavPage(item);
|
|
34
|
+
if (nested) return nested;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects and truncates Claude's canned no-answer sentence
|
|
3
|
+
* ("I don't have information about that in the documentation.") when followed
|
|
4
|
+
* by a self-contradicting "However..." — a prompt-adherence failure mode of
|
|
5
|
+
* Haiku 4.5. Tolerates curly-vs-straight apostrophes and surrounding markdown
|
|
6
|
+
* emphasis.
|
|
7
|
+
*
|
|
8
|
+
* ASSUMPTION: the sentence is reserved for the no-answer reply. A docs page
|
|
9
|
+
* that legitimately contains the phrase would have content silently truncated.
|
|
10
|
+
*/
|
|
11
|
+
export const HEDGE_SENTENCE = "I don't have information about that in the documentation.";
|
|
12
|
+
|
|
13
|
+
const HEDGE_REGEX = /I\s+don['\u2018\u2019]t\s+have\s+information\s+about\s+that\s+in\s+the\s+documentation\./i;
|
|
14
|
+
|
|
15
|
+
export function stripHedge(markdown: string): string {
|
|
16
|
+
if (!markdown) return markdown;
|
|
17
|
+
const match = HEDGE_REGEX.exec(markdown);
|
|
18
|
+
if (!match) return markdown;
|
|
19
|
+
return markdown.slice(0, match.index + match[0].length);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when the reply is (or contains) the canned no-answer sentence.
|
|
24
|
+
* Used to suppress misleading citation fallbacks on hedge replies.
|
|
25
|
+
*/
|
|
26
|
+
export function isHedgeReply(markdown: string): boolean {
|
|
27
|
+
const stripped = stripHedge(markdown);
|
|
28
|
+
return stripped.length !== markdown.length || stripped.trim() === HEDGE_SENTENCE;
|
|
29
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { log } from './logger';
|
|
9
|
+
import { isIsrMode } from './page-isr-helpers';
|
|
9
10
|
import {
|
|
10
11
|
resolveProjectFromHostname,
|
|
11
12
|
resolveCustomDomain,
|
|
@@ -83,7 +84,7 @@ export async function handleProjectResolution(
|
|
|
83
84
|
const hostname = request.headers.get('host') || '';
|
|
84
85
|
|
|
85
86
|
// Skip if not in ISR mode
|
|
86
|
-
if (
|
|
87
|
+
if (!isIsrMode()) {
|
|
87
88
|
return { projectSlug: null, hostAtDocs: true, skip: true };
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function langSpecPath(specPath: string, lang: string): string {
|
|
2
|
+
const lastSlash = specPath.lastIndexOf('/');
|
|
3
|
+
const fileName = specPath.slice(lastSlash + 1);
|
|
4
|
+
const dot = fileName.lastIndexOf('.');
|
|
5
|
+
if (dot === -1) return `${specPath}.${lang}`;
|
|
6
|
+
const base = fileName.slice(0, dot);
|
|
7
|
+
const ext = fileName.slice(dot);
|
|
8
|
+
const dir = specPath.slice(0, lastSlash + 1);
|
|
9
|
+
return `${dir}${base}.${lang}${ext}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function candidateSpecPaths(specPath: string, lang: string | undefined): string[] {
|
|
13
|
+
if (!lang || lang === 'en') return [specPath];
|
|
14
|
+
if (/^https?:\/\//i.test(specPath)) return [specPath];
|
|
15
|
+
return [langSpecPath(specPath, lang), specPath];
|
|
16
|
+
}
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* Check if ISR mode is enabled.
|
|
10
10
|
*/
|
|
11
11
|
export function isIsrMode(): boolean {
|
|
12
|
-
|
|
12
|
+
// Trim guards against trailing whitespace/newline picked up when the env
|
|
13
|
+
// var is set via the Vercel UI or piped-stdin CLI — a single stray \n
|
|
14
|
+
// silently disables ISR mode and returns 404 for every project.
|
|
15
|
+
return process.env.ISR_MODE?.trim() === 'true';
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { DocsConfig } from './docs-types.js';
|
|
2
2
|
import { matchPublicPath } from './glob-match.js';
|
|
3
|
+
import { findFirstNavPage } from './find-first-nav-page.js';
|
|
4
|
+
|
|
5
|
+
export { findFirstNavPage };
|
|
3
6
|
|
|
4
7
|
interface PageWithFrontmatter {
|
|
5
8
|
slug: string;
|
|
@@ -11,48 +14,6 @@ interface Input {
|
|
|
11
14
|
pagesWithFrontmatter: PageWithFrontmatter[];
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
/**
|
|
15
|
-
* Walk the navigation tree and return the slug of the first leaf page.
|
|
16
|
-
* Matches the resolution used by `app/[[...slug]]/page.tsx:findFirstPage`
|
|
17
|
-
* so specific-pages mode can keep the root path public when the first
|
|
18
|
-
* nav page is public. Returns null if no leaf page is found.
|
|
19
|
-
*/
|
|
20
|
-
export function findFirstNavPage(nav: unknown): string | null {
|
|
21
|
-
if (!nav || typeof nav !== 'object') return null;
|
|
22
|
-
const node = nav as Record<string, unknown>;
|
|
23
|
-
const pages = node.pages;
|
|
24
|
-
|
|
25
|
-
if (Array.isArray(pages)) {
|
|
26
|
-
for (const p of pages) {
|
|
27
|
-
if (typeof p === 'string' && p.length > 0) return '/' + p;
|
|
28
|
-
if (p && typeof p === 'object') {
|
|
29
|
-
const pageVal = (p as Record<string, unknown>).page;
|
|
30
|
-
if (typeof pageVal === 'string' && pageVal.length > 0) {
|
|
31
|
-
return '/' + pageVal;
|
|
32
|
-
}
|
|
33
|
-
const nested = findFirstNavPage(p);
|
|
34
|
-
if (nested) return nested;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
// A group is an atomic container — exhaust its pages rather than
|
|
38
|
-
// falling through to the sibling-key loop below (which is for
|
|
39
|
-
// top-level nav shapes like tabs/anchors/languages, not group contents).
|
|
40
|
-
if ('group' in node) return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
|
|
44
|
-
const arr = node[key];
|
|
45
|
-
if (Array.isArray(arr)) {
|
|
46
|
-
for (const item of arr) {
|
|
47
|
-
const nested = findFirstNavPage(item);
|
|
48
|
-
if (nested) return nested;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
17
|
function collectPublicGroupPages(
|
|
57
18
|
nav: unknown,
|
|
58
19
|
inPublicGroup: boolean = false,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight query rewriter for AI chat retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Translates vague/natural-language user queries into doc-search vocabulary
|
|
5
|
+
* using Claude Haiku. Runs in parallel with the original query in the chat
|
|
6
|
+
* route, so latency is masked — if the rewrite is slow or fails, the caller
|
|
7
|
+
* falls back to the original query without penalty.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Returns null on ANY failure path (no client, API error, empty response).
|
|
11
|
+
* Callers must handle null by using the original query.
|
|
12
|
+
* - max_tokens is capped at 80 — rewrites are 3-10 words typically.
|
|
13
|
+
* - Conversation history is included so follow-ups like "what about the other
|
|
14
|
+
* one" can be disambiguated against the prior user message.
|
|
15
|
+
*/
|
|
16
|
+
import { getAnthropicClient } from '@/lib/anthropic-client';
|
|
17
|
+
|
|
18
|
+
const REWRITE_MODEL = 'claude-haiku-4-5-20251001';
|
|
19
|
+
const MAX_REWRITE_CHARS = 200;
|
|
20
|
+
|
|
21
|
+
export const SYSTEM_PROMPT = `You rewrite a user's chat question into a short documentation search query.
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
- Output ONLY the rewritten query — no quotes, no explanation, no prefix.
|
|
25
|
+
- Use terminology that would appear in technical documentation.
|
|
26
|
+
- Keep it short (3-10 words is ideal).
|
|
27
|
+
- If the user's question already uses technical vocabulary, output it unchanged.
|
|
28
|
+
- If conversation history is provided, use it to disambiguate references like "the other one" or "that".
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
Q: how do I make my docs live → deploy documentation site
|
|
32
|
+
Q: change colors → theme customization
|
|
33
|
+
Q: can it do auth → authentication setup
|
|
34
|
+
Q: what about the other one (after discussing analytics) → link analytics
|
|
35
|
+
Q: what is the most recent feature → changelog latest updates new features
|
|
36
|
+
Q: what's new → changelog new features updates
|
|
37
|
+
Q: latest release → changelog release notes updates
|
|
38
|
+
Q: any updates recently → changelog recent updates new features`;
|
|
39
|
+
|
|
40
|
+
export interface HistoryMessage {
|
|
41
|
+
role: 'user' | 'assistant';
|
|
42
|
+
content: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrite a user query into doc-search vocabulary.
|
|
47
|
+
* Returns null on any failure — caller should fall back to the original query.
|
|
48
|
+
*/
|
|
49
|
+
export async function rewriteQueryForSearch(
|
|
50
|
+
message: string,
|
|
51
|
+
history: HistoryMessage[],
|
|
52
|
+
): Promise<string | null> {
|
|
53
|
+
const anthropic = getAnthropicClient();
|
|
54
|
+
if (!anthropic) return null;
|
|
55
|
+
|
|
56
|
+
// Compose the user prompt: include the last user message from history (if any)
|
|
57
|
+
// so follow-up references resolve. Ignore assistant replies — they'd contaminate
|
|
58
|
+
// the rewrite with doc-flavored phrasing that might mask the user's real intent.
|
|
59
|
+
const priorUserMsg = [...history].reverse().find(
|
|
60
|
+
h => h.role === 'user' && h.content !== message,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const userPrompt = priorUserMsg
|
|
64
|
+
? `Previous question: ${priorUserMsg.content}\nCurrent question: ${message}\n\nRewrite the current question:`
|
|
65
|
+
: `Question: ${message}\n\nRewrite:`;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await anthropic.messages.create({
|
|
69
|
+
model: REWRITE_MODEL,
|
|
70
|
+
max_tokens: 80,
|
|
71
|
+
temperature: 0.1,
|
|
72
|
+
system: SYSTEM_PROMPT,
|
|
73
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const textBlock = response.content.find(c => c.type === 'text');
|
|
77
|
+
if (!textBlock || textBlock.type !== 'text') return null;
|
|
78
|
+
|
|
79
|
+
let rewrite = textBlock.text.trim();
|
|
80
|
+
|
|
81
|
+
// Strip surrounding quotes if Claude wrapped the output
|
|
82
|
+
rewrite = rewrite.replace(/^["'`]+|["'`]+$/g, '').trim();
|
|
83
|
+
|
|
84
|
+
if (!rewrite) return null;
|
|
85
|
+
|
|
86
|
+
// Defensive cap — prevents a runaway response from bloating the search query
|
|
87
|
+
return rewrite.slice(0, MAX_REWRITE_CHARS);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { LanguageCode } from './docs-types';
|
|
2
|
+
|
|
3
|
+
export interface UiStrings {
|
|
4
|
+
search: string;
|
|
5
|
+
askAi: string;
|
|
6
|
+
more: string;
|
|
7
|
+
toggleMenu: string;
|
|
8
|
+
selectLanguage: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const EN: UiStrings = {
|
|
12
|
+
search: 'Search',
|
|
13
|
+
askAi: 'Ask AI',
|
|
14
|
+
more: 'More',
|
|
15
|
+
toggleMenu: 'Toggle menu',
|
|
16
|
+
selectLanguage: 'Select language',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ZH: Partial<UiStrings> = {
|
|
20
|
+
search: '搜索',
|
|
21
|
+
askAi: '询问 AI',
|
|
22
|
+
more: '更多',
|
|
23
|
+
toggleMenu: '切换菜单',
|
|
24
|
+
selectLanguage: '选择语言',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const STRINGS: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
|
|
28
|
+
en: EN,
|
|
29
|
+
fr: {
|
|
30
|
+
search: 'Rechercher',
|
|
31
|
+
askAi: "Demander à l'IA",
|
|
32
|
+
more: 'Plus',
|
|
33
|
+
toggleMenu: 'Basculer le menu',
|
|
34
|
+
selectLanguage: 'Choisir la langue',
|
|
35
|
+
},
|
|
36
|
+
es: {
|
|
37
|
+
search: 'Buscar',
|
|
38
|
+
askAi: 'Preguntar a la IA',
|
|
39
|
+
more: 'Más',
|
|
40
|
+
toggleMenu: 'Alternar menú',
|
|
41
|
+
selectLanguage: 'Elegir idioma',
|
|
42
|
+
},
|
|
43
|
+
zh: ZH,
|
|
44
|
+
cn: ZH,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function getUiStrings(lang: LanguageCode | undefined): UiStrings {
|
|
48
|
+
if (!lang) return EN;
|
|
49
|
+
const overrides = STRINGS[lang];
|
|
50
|
+
if (!overrides) return EN;
|
|
51
|
+
return { ...EN, ...overrides };
|
|
52
|
+
}
|
|
@@ -44,8 +44,8 @@ const HYBRID_QUERY_OPTS = {
|
|
|
44
44
|
*/
|
|
45
45
|
const MIN_SCORE = 0.3;
|
|
46
46
|
|
|
47
|
-
/** Max chunks per page —
|
|
48
|
-
const MAX_CHUNKS_PER_PAGE =
|
|
47
|
+
/** Max chunks per page — raised from 3 to 4 to match broader topK retrieval budget */
|
|
48
|
+
const MAX_CHUNKS_PER_PAGE = 4;
|
|
49
49
|
|
|
50
50
|
/** Create a namespaced Upstash Vector index for a project. */
|
|
51
51
|
function getNamespace(projectId: string) {
|
|
@@ -80,7 +80,9 @@ export async function upsertChunks(
|
|
|
80
80
|
await ns.upsert(
|
|
81
81
|
batch.map(c => ({
|
|
82
82
|
id: c.id,
|
|
83
|
-
|
|
83
|
+
// Prefix + body goes to Upstash for embedding/BM25; metadata.content
|
|
84
|
+
// stays prefix-free so consumers display clean body text.
|
|
85
|
+
data: c.prefix + c.content,
|
|
84
86
|
metadata: {
|
|
85
87
|
pageSlug: c.pageSlug,
|
|
86
88
|
sectionHeading: c.sectionHeading,
|
|
@@ -303,6 +303,11 @@
|
|
|
303
303
|
"label": {
|
|
304
304
|
"type": "string"
|
|
305
305
|
},
|
|
306
|
+
"labels": {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"additionalProperties": { "type": "string" },
|
|
309
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
|
|
310
|
+
},
|
|
306
311
|
"icon": {
|
|
307
312
|
"$ref": "#/definitions/iconSchema",
|
|
308
313
|
"description": "Icon to display alongside this navigation item"
|
|
@@ -331,6 +336,11 @@
|
|
|
331
336
|
"label": {
|
|
332
337
|
"type": "string"
|
|
333
338
|
},
|
|
339
|
+
"labels": {
|
|
340
|
+
"type": "object",
|
|
341
|
+
"additionalProperties": { "type": "string" },
|
|
342
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
|
|
343
|
+
},
|
|
334
344
|
"href": {
|
|
335
345
|
"$ref": "#/definitions/hrefSchema"
|
|
336
346
|
}
|
|
@@ -349,6 +359,11 @@
|
|
|
349
359
|
"type": "string",
|
|
350
360
|
"const": "github"
|
|
351
361
|
},
|
|
362
|
+
"labels": {
|
|
363
|
+
"type": "object",
|
|
364
|
+
"additionalProperties": { "type": "string" },
|
|
365
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to 'GitHub'."
|
|
366
|
+
},
|
|
352
367
|
"href": {
|
|
353
368
|
"$ref": "#/definitions/hrefSchema"
|
|
354
369
|
}
|