jamdesk 1.1.37 → 1.1.38
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/dist/__tests__/integration/init.integration.test.js +41 -0
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js +2 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/package.json +1 -1
- package/templates/api-reference/openapi-example.mdx +55 -0
- package/templates/api-reference/request-response-examples.mdx +210 -0
- package/templates/docs.json +27 -0
- package/templates/openapi/example-api.yaml +185 -0
- package/vendored/app/[[...slug]]/page.tsx +26 -8
- package/vendored/app/api/chat/[project]/route.ts +53 -3
- package/vendored/app/api/docs-search/[project]/search/route.ts +83 -3
- package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
- package/vendored/components/search/SearchModal.tsx +7 -14
- package/vendored/hooks/useChat.ts +22 -4
- package/vendored/lib/chat-prompt.ts +1 -1
- package/vendored/lib/chat-tools.ts +3 -0
- package/vendored/lib/embedding-chunker.ts +18 -2
- package/vendored/lib/language-codes.json +27 -0
- package/vendored/lib/language-utils.ts +74 -5
- package/vendored/lib/link-rewriter.ts +67 -0
- package/vendored/lib/locale-helpers.ts +62 -0
- package/vendored/lib/openapi/code-examples.ts +5 -6
- package/vendored/lib/openapi/derive-auth.ts +46 -0
- package/vendored/lib/openapi/index.ts +7 -0
- package/vendored/lib/openapi/parser.ts +7 -2
- package/vendored/lib/openapi/resolve-server-url.ts +14 -0
- package/vendored/lib/openapi/types.ts +2 -0
- package/vendored/lib/path-safety.ts +96 -0
- package/vendored/lib/search-client.ts +67 -10
- package/vendored/lib/static-artifacts.ts +25 -1
- package/vendored/lib/vector-store.ts +70 -17
- package/vendored/scripts/build-search-index.cjs +59 -0
- package/vendored/themes/base.css +5 -0
- package/vendored/workspace-package-lock.json +6 -6
|
@@ -48,7 +48,6 @@ import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
|
|
|
48
48
|
import { mdxSecurityOptions } from '@/lib/mdx-security-options';
|
|
49
49
|
import fs from 'fs';
|
|
50
50
|
import path from 'path';
|
|
51
|
-
import matter from 'gray-matter';
|
|
52
51
|
import { getContentDir } from '@/lib/docs';
|
|
53
52
|
import type { DocsConfig } from '@/lib/docs-types';
|
|
54
53
|
import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
|
|
@@ -61,7 +60,6 @@ import {
|
|
|
61
60
|
normalizeSlugForContent,
|
|
62
61
|
parseFrontmatter,
|
|
63
62
|
projectExists,
|
|
64
|
-
type ContentLoader,
|
|
65
63
|
} from '@/lib/content-loader';
|
|
66
64
|
import { getBaseUrl, pathToSlug } from '@/lib/page-isr-helpers';
|
|
67
65
|
import {
|
|
@@ -70,6 +68,7 @@ import {
|
|
|
70
68
|
parseEndpoint,
|
|
71
69
|
generateCodeExamples,
|
|
72
70
|
formatOpenApiWarning,
|
|
71
|
+
deriveAuthFromSecurity,
|
|
73
72
|
type OpenApiEndpointData,
|
|
74
73
|
type CodeExample,
|
|
75
74
|
type AuthMethod,
|
|
@@ -134,6 +133,22 @@ function resolveBaseUrl(
|
|
|
134
133
|
return process.env.SITE_URL || DEFAULT_SITE_URL;
|
|
135
134
|
}
|
|
136
135
|
|
|
136
|
+
/**
|
|
137
|
+
* docs.json override is treated as a UNIT — if method is set, both method and name
|
|
138
|
+
* come from docs.json, avoiding a stale customer-set `name` pairing with a
|
|
139
|
+
* spec-derived `method`. Falls back to deriving auth from the OpenAPI security schemes.
|
|
140
|
+
*/
|
|
141
|
+
function resolveAuth(
|
|
142
|
+
endpoint: OpenApiEndpointData | null | undefined,
|
|
143
|
+
config: DocsConfig,
|
|
144
|
+
): { method?: AuthMethod; headerName?: string } {
|
|
145
|
+
const override = config.api?.mdx?.auth;
|
|
146
|
+
if (override?.method) {
|
|
147
|
+
return { method: override.method, headerName: override.name };
|
|
148
|
+
}
|
|
149
|
+
return endpoint ? deriveAuthFromSecurity(endpoint.security) : {};
|
|
150
|
+
}
|
|
151
|
+
|
|
137
152
|
/**
|
|
138
153
|
* Frontmatter data from MDX files.
|
|
139
154
|
*/
|
|
@@ -548,9 +563,9 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
548
563
|
|
|
549
564
|
// Generate code examples
|
|
550
565
|
if (openApiEndpointData) {
|
|
551
|
-
const authMethod
|
|
566
|
+
const { method: authMethod, headerName: authHeaderName } = resolveAuth(openApiEndpointData, config);
|
|
552
567
|
const languages = config.api?.examples?.languages;
|
|
553
|
-
openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, languages });
|
|
568
|
+
openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, authHeaderName, languages });
|
|
554
569
|
}
|
|
555
570
|
} catch (err) {
|
|
556
571
|
const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
|
|
@@ -616,6 +631,9 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
616
631
|
mdxEndpointData = buildEndpointFromMdx(mdxApiMethod, mdxApiPath, paramFields, fallbackServerUrl);
|
|
617
632
|
}
|
|
618
633
|
|
|
634
|
+
const resolvedMdxAuth = resolveAuth(mdxEndpointData, config);
|
|
635
|
+
const resolvedOpenApiAuth = resolveAuth(openApiEndpointData, config);
|
|
636
|
+
|
|
619
637
|
// For API pages, wrap the entire content area with ApiPageWrapper
|
|
620
638
|
// so code panels can be positioned as siblings at the page level
|
|
621
639
|
if (isApiPage) {
|
|
@@ -653,8 +671,8 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
653
671
|
endpoint={mdxEndpointData}
|
|
654
672
|
playgroundOnly
|
|
655
673
|
playgroundDisplay={playgroundDisplay}
|
|
656
|
-
authMethod={
|
|
657
|
-
authHeaderName={
|
|
674
|
+
authMethod={resolvedMdxAuth.method}
|
|
675
|
+
authHeaderName={resolvedMdxAuth.headerName}
|
|
658
676
|
serverUrl={fallbackServerUrl}
|
|
659
677
|
proxyEnabled={proxyEnabled}
|
|
660
678
|
languages={config.api?.examples?.languages}
|
|
@@ -674,8 +692,8 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
674
692
|
endpoint={openApiEndpointData}
|
|
675
693
|
codeExamples={openApiCodeExamples || undefined}
|
|
676
694
|
playgroundDisplay={playgroundDisplay}
|
|
677
|
-
authMethod={
|
|
678
|
-
authHeaderName={
|
|
695
|
+
authMethod={resolvedOpenApiAuth.method}
|
|
696
|
+
authHeaderName={resolvedOpenApiAuth.headerName}
|
|
679
697
|
serverUrl={fallbackServerUrl}
|
|
680
698
|
proxyEnabled={proxyEnabled}
|
|
681
699
|
languages={config.api?.examples?.languages}
|
|
@@ -30,6 +30,7 @@ import { rewriteQueryForSearch } from '@/lib/query-rewriter';
|
|
|
30
30
|
import { fetchDocsConfig } from '@/lib/r2-content';
|
|
31
31
|
import { redis } from '@/lib/redis';
|
|
32
32
|
import { CHAT_TOOLS } from '@/lib/chat-tools';
|
|
33
|
+
import { rewriteSlugLinks } from '@/lib/link-rewriter';
|
|
33
34
|
import { log } from '@/lib/logger';
|
|
34
35
|
|
|
35
36
|
export const runtime = 'nodejs';
|
|
@@ -43,6 +44,7 @@ const MAX_HISTORY = 10;
|
|
|
43
44
|
* Used both for searchQuery enrichment and to skip the rewriter. */
|
|
44
45
|
const SHORT_FOLLOWUP_LEN = 60;
|
|
45
46
|
const VALID_ROLES = new Set<string>(['user', 'assistant']);
|
|
47
|
+
const VALID_LOCALE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
|
|
46
48
|
|
|
47
49
|
export async function POST(
|
|
48
50
|
request: NextRequest,
|
|
@@ -75,7 +77,7 @@ export async function POST(
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
let body: { message: unknown; history?: unknown[] };
|
|
80
|
+
let body: { message: unknown; history?: unknown[]; locale?: unknown };
|
|
79
81
|
try {
|
|
80
82
|
body = await request.json();
|
|
81
83
|
} catch {
|
|
@@ -88,6 +90,14 @@ export async function POST(
|
|
|
88
90
|
return Response.json({ error: 'Invalid message' }, { status: 400 });
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
let clientLocale: string | undefined;
|
|
94
|
+
if (body.locale !== undefined) {
|
|
95
|
+
if (typeof body.locale !== 'string' || !VALID_LOCALE_RE.test(body.locale)) {
|
|
96
|
+
return Response.json({ error: 'Invalid locale' }, { status: 400 });
|
|
97
|
+
}
|
|
98
|
+
clientLocale = body.locale;
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
// Sanitize history: only allow valid roles, string content, capped length
|
|
92
102
|
const history = (Array.isArray(rawHistory) ? rawHistory : [])
|
|
93
103
|
.filter((h): h is { role: 'user' | 'assistant'; content: string } => {
|
|
@@ -122,6 +132,29 @@ export async function POST(
|
|
|
122
132
|
fetchDocsConfig(project).catch(() => null),
|
|
123
133
|
]);
|
|
124
134
|
|
|
135
|
+
// Per-project rollout gate. Filter applies ONLY when the client sent a
|
|
136
|
+
// locale AND the project is opted in. Both default to off so this is a
|
|
137
|
+
// safe deploy. Set the flag with: redis-cli SET chatLocaleFilter:<slug> true
|
|
138
|
+
//
|
|
139
|
+
// Skip the Redis read entirely when no client locale was sent — the filter
|
|
140
|
+
// can never apply, so the round-trip would just block vector queries for
|
|
141
|
+
// nothing. This matters because the vector query path below depends on
|
|
142
|
+
// `effectiveLocale` and would otherwise wait on this read serially.
|
|
143
|
+
const localeFilterEnabled = clientLocale && redis
|
|
144
|
+
? await redis.get(`chatLocaleFilter:${project}`)
|
|
145
|
+
.then((v) => v === 'true')
|
|
146
|
+
.catch((err) => {
|
|
147
|
+
// Fail-open is intentional (don't 500 chat on a Redis blip), but
|
|
148
|
+
// SREs need a signal — without this log, intermittent Redis
|
|
149
|
+
// outages silently regress the locale-pollution fix.
|
|
150
|
+
log('warn', 'chat: locale flag read failed, defaulting filter off', {
|
|
151
|
+
project, error: String(err),
|
|
152
|
+
});
|
|
153
|
+
return false;
|
|
154
|
+
})
|
|
155
|
+
: false;
|
|
156
|
+
const effectiveLocale = (localeFilterEnabled && clientLocale) ? clientLocale : undefined;
|
|
157
|
+
|
|
125
158
|
// Fully parallel retrieval:
|
|
126
159
|
// - Original vector query runs immediately.
|
|
127
160
|
// - Rewriter + rewritten-query vector search are chained into ONE promise,
|
|
@@ -130,7 +163,8 @@ export async function POST(
|
|
|
130
163
|
// max(original, rewriter) + rewritten_query_time.
|
|
131
164
|
// Any failure in the rewrite path resolves to an empty chunk list — the
|
|
132
165
|
// original query still returns results so chat doesn't fail.
|
|
133
|
-
|
|
166
|
+
|
|
167
|
+
const originalQueryPromise = querySimilarChunks(project, searchQuery, 15, { locale: effectiveLocale });
|
|
134
168
|
|
|
135
169
|
// Skipping the rewriter on short follow-ups avoids 500-2000ms of serial
|
|
136
170
|
// Anthropic latency — `searchQuery` above is already enriched with prior-turn
|
|
@@ -145,7 +179,7 @@ export async function POST(
|
|
|
145
179
|
// (which is enriched with prior-turn context for short follow-ups).
|
|
146
180
|
// The rewriter only sees `message`, so a no-op rewrite equals `message`.
|
|
147
181
|
if (!rewritten || rewritten === message) return [];
|
|
148
|
-
return querySimilarChunks(project, rewritten, 15).catch(() => []);
|
|
182
|
+
return querySimilarChunks(project, rewritten, 15, { locale: effectiveLocale }).catch(() => []);
|
|
149
183
|
});
|
|
150
184
|
|
|
151
185
|
let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
@@ -196,6 +230,12 @@ export async function POST(
|
|
|
196
230
|
const baseUrl = getBaseUrl(project, originalHost);
|
|
197
231
|
const siteName = resolveSiteName(config);
|
|
198
232
|
|
|
233
|
+
// Map of pageSlug → canonical absolute URL, consumed by rewriteSlugLinks
|
|
234
|
+
// after the stream completes to canonicalize Haiku-generated link targets.
|
|
235
|
+
const slugToUrl: Record<string, string> = Object.fromEntries(
|
|
236
|
+
chunks.map((c) => [c.pageSlug, `${baseUrl}${docsPath}/${c.pageSlug}`]),
|
|
237
|
+
);
|
|
238
|
+
|
|
199
239
|
const systemPrompt = buildSystemPrompt(siteName, chunks, baseUrl, docsPath);
|
|
200
240
|
|
|
201
241
|
const stream = anthropic.messages.stream({
|
|
@@ -344,6 +384,16 @@ export async function POST(
|
|
|
344
384
|
: explicitSources.length > 0
|
|
345
385
|
? explicitSources
|
|
346
386
|
: topChunksAsCitations(2, seen);
|
|
387
|
+
|
|
388
|
+
// Two-pass URL rewrite: stream raw markdown live (for typing-effect
|
|
389
|
+
// UX), then emit a `text_replace` event with canonical absolute URLs
|
|
390
|
+
// once we have the full final markdown. Brief artifact: between the
|
|
391
|
+
// last text chunk and this event the user sees rendered slug-form
|
|
392
|
+
// URLs. Skip the event when nothing changed.
|
|
393
|
+
const rewrittenMarkdown = rewriteSlugLinks(markdownText, slugToUrl);
|
|
394
|
+
if (rewrittenMarkdown !== markdownText) {
|
|
395
|
+
controller.enqueue(sendEvent({ type: 'text_replace', content: rewrittenMarkdown }));
|
|
396
|
+
}
|
|
347
397
|
} else if (toolUse.name === 'ask_clarification') {
|
|
348
398
|
const input = toolUse.input as ClarificationInput;
|
|
349
399
|
const options = Array.isArray(input.options) ? input.options : [];
|
|
@@ -20,6 +20,9 @@ import { querySimilarChunks } from '@/lib/vector-store';
|
|
|
20
20
|
import { verifyApiKey } from '@/lib/docs-search-auth';
|
|
21
21
|
import { getBaseUrl, trackServerAnalytics } from '@/lib/route-helpers';
|
|
22
22
|
import { redis } from '@/lib/redis';
|
|
23
|
+
import { fetchDocsConfig } from '@/lib/r2-content';
|
|
24
|
+
import { isMultiLanguageConfig } from '@/lib/navigation-utils';
|
|
25
|
+
import { log } from '@/lib/logger';
|
|
23
26
|
|
|
24
27
|
export const runtime = 'nodejs';
|
|
25
28
|
export const maxDuration = 30;
|
|
@@ -34,6 +37,11 @@ const MAX_LIMIT = 20;
|
|
|
34
37
|
const DEFAULT_LIMIT = 5;
|
|
35
38
|
const MAX_QUERY_LENGTH = 500;
|
|
36
39
|
const RATE_LIMIT_PER_MIN = 60;
|
|
40
|
+
/** BCP-47-ish: 2-3 letter primary tag, optional 2-4 letter region/script.
|
|
41
|
+
* Matches the chat endpoint's VALID_LOCALE_RE — keep both contracts in sync.
|
|
42
|
+
* Limitation: 3-segment tags like `zh-Hant-HK` are rejected. Documented. */
|
|
43
|
+
const VALID_LANGUAGE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
|
|
44
|
+
const DEFAULT_LANGUAGE = 'en';
|
|
37
45
|
|
|
38
46
|
export async function OPTIONS(_request: NextRequest) {
|
|
39
47
|
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
|
@@ -89,7 +97,7 @@ export async function POST(
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
// --- Parse & validate request body ---
|
|
92
|
-
let body: { query?: string; limit?: number };
|
|
100
|
+
let body: { query?: string; limit?: number; language?: unknown };
|
|
93
101
|
try {
|
|
94
102
|
body = await request.json();
|
|
95
103
|
} catch {
|
|
@@ -101,6 +109,23 @@ export async function POST(
|
|
|
101
109
|
|
|
102
110
|
const { query, limit: rawLimit } = body;
|
|
103
111
|
|
|
112
|
+
// Validate language. `null` and `undefined` both default to 'en'
|
|
113
|
+
// (real-world clients send `language: null` from `?? null` fallbacks —
|
|
114
|
+
// rejecting them as 400 would be gratuitous). A non-string value or a
|
|
115
|
+
// string that doesn't match the BCP-47 pattern is a 400.
|
|
116
|
+
let language: string = DEFAULT_LANGUAGE;
|
|
117
|
+
let languageWasExplicit = false;
|
|
118
|
+
if (body.language != null) {
|
|
119
|
+
if (typeof body.language !== 'string' || !VALID_LANGUAGE_RE.test(body.language)) {
|
|
120
|
+
return NextResponse.json(
|
|
121
|
+
{ error: 'Invalid language code' },
|
|
122
|
+
{ status: 400, headers: CORS_HEADERS },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
language = body.language;
|
|
126
|
+
languageWasExplicit = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
105
130
|
return NextResponse.json(
|
|
106
131
|
{ error: 'Missing or empty "query" field' },
|
|
@@ -125,11 +150,66 @@ export async function POST(
|
|
|
125
150
|
MAX_LIMIT,
|
|
126
151
|
);
|
|
127
152
|
|
|
153
|
+
// --- Multi-language gate ---
|
|
154
|
+
// Apply the locale filter only when the project is configured for
|
|
155
|
+
// multiple languages AND the per-project kill switch is not set.
|
|
156
|
+
// Single-language projects' chunks have no locale metadata, so the
|
|
157
|
+
// strict filter would return zero. Run the config fetch and kill-switch
|
|
158
|
+
// read in parallel — fetchDocsConfig has a 5-min in-memory cache, and
|
|
159
|
+
// Redis is the bottleneck only on a cold cache. .catch() on the kill
|
|
160
|
+
// switch keeps a Redis blip from breaking searches.
|
|
161
|
+
const killSwitchPromise: Promise<boolean> = redis
|
|
162
|
+
? redis.get(`searchLocaleFilter:${project}`)
|
|
163
|
+
.then((v) => v === 'disabled')
|
|
164
|
+
.catch(() => false)
|
|
165
|
+
: Promise.resolve(false);
|
|
166
|
+
|
|
167
|
+
let config: Awaited<ReturnType<typeof fetchDocsConfig>>;
|
|
168
|
+
let killSwitch: boolean;
|
|
169
|
+
try {
|
|
170
|
+
[config, killSwitch] = await Promise.all([
|
|
171
|
+
fetchDocsConfig(project),
|
|
172
|
+
killSwitchPromise,
|
|
173
|
+
]);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// R2 outage — config unavailable. Asymmetric fail-mode:
|
|
176
|
+
// - Caller sent an explicit language: 503 (don't silently leak
|
|
177
|
+
// cross-language results to a caller who asked for filtering).
|
|
178
|
+
// - Caller defaulted to 'en': fall back to today's no-filter behavior
|
|
179
|
+
// so docs sites keep working through R2 incidents.
|
|
180
|
+
log('error', 'docs-search: fetchDocsConfig failed', {
|
|
181
|
+
project,
|
|
182
|
+
error: String(err),
|
|
183
|
+
languageWasExplicit,
|
|
184
|
+
});
|
|
185
|
+
if (languageWasExplicit) {
|
|
186
|
+
return NextResponse.json(
|
|
187
|
+
{ error: 'Search temporarily unavailable' },
|
|
188
|
+
{ status: 503, headers: CORS_HEADERS },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
config = null;
|
|
192
|
+
killSwitch = await killSwitchPromise.catch(() => false);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (killSwitch) {
|
|
196
|
+
// Operators have flipped the kill switch for this project — log so we
|
|
197
|
+
// can spot escapes. Not an error, but worth surfacing.
|
|
198
|
+
log('warn', 'docs-search: locale filter kill switch is enabled', {
|
|
199
|
+
project,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const applyLocaleFilter = !killSwitch && !!config && isMultiLanguageConfig(config);
|
|
204
|
+
const effectiveLocale = applyLocaleFilter ? language : undefined;
|
|
205
|
+
|
|
128
206
|
// --- Semantic vector search ---
|
|
129
207
|
const startMs = Date.now();
|
|
130
208
|
let chunks;
|
|
131
209
|
try {
|
|
132
|
-
chunks = await querySimilarChunks(project, query.trim(), limit
|
|
210
|
+
chunks = await querySimilarChunks(project, query.trim(), limit, {
|
|
211
|
+
locale: effectiveLocale,
|
|
212
|
+
});
|
|
133
213
|
} catch (err) {
|
|
134
214
|
console.error('Vector search failed:', err);
|
|
135
215
|
return NextResponse.json(
|
|
@@ -162,7 +242,7 @@ export async function POST(
|
|
|
162
242
|
});
|
|
163
243
|
|
|
164
244
|
return NextResponse.json(
|
|
165
|
-
{ results, query: query.trim(), total: results.length, durationMs },
|
|
245
|
+
{ results, query: query.trim(), language, total: results.length, durationMs },
|
|
166
246
|
{ status: 200, headers: CORS_HEADERS },
|
|
167
247
|
);
|
|
168
248
|
}
|
|
@@ -11,6 +11,7 @@ import { CodePanel, CodePanelTab, getStatusColor } from '../ui/CodePanel';
|
|
|
11
11
|
import { useShikiHighlightMultiple } from '@/hooks/useShikiHighlight';
|
|
12
12
|
import { preloadHighlighter } from '@/lib/shiki-client';
|
|
13
13
|
import ReactMarkdown from 'react-markdown';
|
|
14
|
+
import { resolveServerUrl } from '@/lib/openapi/resolve-server-url';
|
|
14
15
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
15
16
|
import type {
|
|
16
17
|
OpenApiEndpointData,
|
|
@@ -916,7 +917,7 @@ export function OpenApiEndpoint({
|
|
|
916
917
|
const headerParams = parameters.filter(p => p.in === 'header');
|
|
917
918
|
const cookieParams = parameters.filter(p => p.in === 'cookie');
|
|
918
919
|
|
|
919
|
-
const baseUrl = servers[0]
|
|
920
|
+
const baseUrl = resolveServerUrl(servers[0]);
|
|
920
921
|
|
|
921
922
|
// Playground state
|
|
922
923
|
const showPlayground = playgroundDisplay !== 'none';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef, type ReactElement } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { useRouter, usePathname } from 'next/navigation';
|
|
5
5
|
import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
|
|
6
6
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
7
7
|
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
@@ -9,16 +9,7 @@ import { getSuggestions } from '@/lib/search-suggestions';
|
|
|
9
9
|
import { trackSearch } from '@/lib/analytics-client';
|
|
10
10
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
11
11
|
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
12
|
-
|
|
13
|
-
interface SearchResult {
|
|
14
|
-
id: string;
|
|
15
|
-
title: string;
|
|
16
|
-
description?: string;
|
|
17
|
-
content: string;
|
|
18
|
-
slug: string;
|
|
19
|
-
section?: string;
|
|
20
|
-
type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
|
|
21
|
-
}
|
|
12
|
+
import type { SearchResult } from '@/lib/search-client';
|
|
22
13
|
|
|
23
14
|
interface PopularPage {
|
|
24
15
|
title: string;
|
|
@@ -102,6 +93,7 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
|
|
|
102
93
|
export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
|
|
103
94
|
const linkPrefix = useLinkPrefix();
|
|
104
95
|
const projectSlug = useProjectSlug();
|
|
96
|
+
const pathname = usePathname() ?? '';
|
|
105
97
|
const [query, setQuery] = useState('');
|
|
106
98
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
107
99
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -210,8 +202,9 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
210
202
|
|
|
211
203
|
const timer = setTimeout(async () => {
|
|
212
204
|
try {
|
|
213
|
-
const { search } = await import('@/lib/search-client');
|
|
214
|
-
const
|
|
205
|
+
const { search, resolveActiveLocale } = await import('@/lib/search-client');
|
|
206
|
+
const activeLocale = resolveActiveLocale(pathname);
|
|
207
|
+
const searchResults = await search(query, 15, activeLocale);
|
|
215
208
|
if (!cancelled) {
|
|
216
209
|
setResults(searchResults);
|
|
217
210
|
setSelectedIndex(0);
|
|
@@ -235,7 +228,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
235
228
|
cancelled = true;
|
|
236
229
|
clearTimeout(timer);
|
|
237
230
|
};
|
|
238
|
-
}, [query]);
|
|
231
|
+
}, [query, pathname]);
|
|
239
232
|
|
|
240
233
|
// Scroll selected result into view
|
|
241
234
|
useEffect(() => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
4
|
import { createTextStreamPacer, type TextStreamPacer } from './useTextStreamPacer';
|
|
5
5
|
import { useMediaQuery } from './useMediaQuery';
|
|
6
|
+
import { extractLanguageFromPath } from '@/lib/language-utils';
|
|
6
7
|
|
|
7
8
|
export interface Citation {
|
|
8
9
|
title: string;
|
|
@@ -120,13 +121,13 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
120
121
|
const markRevealComplete = useCallback(
|
|
121
122
|
(
|
|
122
123
|
assistantId: string,
|
|
123
|
-
pending: { citations?: Citation[]; clarificationOptions?: string[] },
|
|
124
|
+
pending: { citations?: Citation[]; clarificationOptions?: string[]; replacedContent?: string },
|
|
124
125
|
): void => {
|
|
125
126
|
setMessages((prev) => {
|
|
126
127
|
const idx = prev.findIndex((m) => m.id === assistantId);
|
|
127
128
|
if (idx === -1) return prev;
|
|
128
129
|
const msg = prev[idx];
|
|
129
|
-
if (!msg.content) {
|
|
130
|
+
if (!msg.content && !pending.replacedContent) {
|
|
130
131
|
// Empty bubble — drop it entirely.
|
|
131
132
|
return prev.filter((m) => m.id !== assistantId);
|
|
132
133
|
}
|
|
@@ -134,6 +135,7 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
134
135
|
next[idx] = {
|
|
135
136
|
...msg,
|
|
136
137
|
isStreaming: false,
|
|
138
|
+
...(pending.replacedContent !== undefined ? { content: pending.replacedContent } : {}),
|
|
137
139
|
...(pending.citations ? { citations: pending.citations } : {}),
|
|
138
140
|
...(pending.clarificationOptions
|
|
139
141
|
? { clarificationOptions: pending.clarificationOptions }
|
|
@@ -154,7 +156,7 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
154
156
|
// Citations + clarifications arrive instantly from the server but we
|
|
155
157
|
// want them to appear only after the prose is finished revealing —
|
|
156
158
|
// otherwise the badges/buttons render above text that hasn't been typed.
|
|
157
|
-
const pending: { citations?: Citation[]; clarificationOptions?: string[] } = {};
|
|
159
|
+
const pending: { citations?: Citation[]; clarificationOptions?: string[]; replacedContent?: string } = {};
|
|
158
160
|
|
|
159
161
|
const pacer = createTextStreamPacer({
|
|
160
162
|
reducedMotion,
|
|
@@ -210,6 +212,15 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
210
212
|
pacer.enqueue(event.content || '');
|
|
211
213
|
break;
|
|
212
214
|
|
|
215
|
+
case 'text_replace':
|
|
216
|
+
// Server has finalized the markdown (e.g. rewrote slug links to
|
|
217
|
+
// absolute URLs). Buffer it here and apply at reveal-end so it
|
|
218
|
+
// doesn't snap mid-typing-animation. Brief artifact: between the
|
|
219
|
+
// last text chunk and reveal-end, the user sees the pre-rewrite
|
|
220
|
+
// markdown rendered with non-canonical URLs.
|
|
221
|
+
pending.replacedContent = event.content || '';
|
|
222
|
+
break;
|
|
223
|
+
|
|
213
224
|
case 'citations':
|
|
214
225
|
pending.citations = event.sources;
|
|
215
226
|
break;
|
|
@@ -304,11 +315,18 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
304
315
|
? '/docs/_chat'
|
|
305
316
|
: endpoint;
|
|
306
317
|
|
|
318
|
+
// extractLanguageFromPath returns undefined for root-level paths (default
|
|
319
|
+
// language). We omit `locale` in that case rather than guessing — the server
|
|
320
|
+
// only filters when a locale is explicitly sent AND the per-project flag is
|
|
321
|
+
// on, so omitting is the correct "use whatever you have, no filter" signal
|
|
322
|
+
// that's safe across mid-rollout client deployments.
|
|
323
|
+
const locale = extractLanguageFromPath(window.location.pathname);
|
|
324
|
+
|
|
307
325
|
try {
|
|
308
326
|
const response = await fetch(chatUrl, {
|
|
309
327
|
method: 'POST',
|
|
310
328
|
headers: { 'Content-Type': 'application/json' },
|
|
311
|
-
body: JSON.stringify({ message: trimmed, history }),
|
|
329
|
+
body: JSON.stringify(locale ? { message: trimmed, history, locale } : { message: trimmed, history }),
|
|
312
330
|
signal: controller.signal,
|
|
313
331
|
});
|
|
314
332
|
|
|
@@ -99,7 +99,7 @@ Rules:
|
|
|
99
99
|
- Use markdown formatting, including code blocks with language hints when showing code.
|
|
100
100
|
- Never make up information not in the context.
|
|
101
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
|
|
102
|
+
(a) RELEVANT PAGE EXISTS → answer by referring to it with a markdown link. The link's URL must be either the pageSlug (e.g. "See [OpenAPI Example](api-reference/openapi-example) for a live endpoint page.") OR the full URL from the URL: line of the cited chunk. The system rewrites both forms to the canonical absolute URL automatically. Never invent a URL or use a relative path like ./ or ../. Do NOT prefix with "I don't have information".
|
|
103
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
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
105
|
- Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
|
|
@@ -64,6 +64,9 @@ export const ANSWER_TOOL: AnswerToolSchema = {
|
|
|
64
64
|
type: 'string',
|
|
65
65
|
description:
|
|
66
66
|
'The answer in markdown. Use code blocks with language hints when showing code. ' +
|
|
67
|
+
'When linking to a documentation page, prefer the pageSlug (from the [pageSlug: ...] label) as the URL: ' +
|
|
68
|
+
'[Anchor Text](api-reference/openapi-example). The full URL from the URL: line also works. ' +
|
|
69
|
+
'The system rewrites either form to the canonical absolute URL after generation. ' +
|
|
67
70
|
'Do not embed citation text like "[Page Title]" — citations are listed in cited_page_slugs.',
|
|
68
71
|
},
|
|
69
72
|
cited_page_slugs: {
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* first, then at sentence boundaries for oversized sections.
|
|
7
7
|
*/
|
|
8
8
|
import { extractSections } from './static-artifacts.js';
|
|
9
|
+
import {
|
|
10
|
+
deriveChunkLocale,
|
|
11
|
+
normalizeLanguageList,
|
|
12
|
+
type LanguageEntry,
|
|
13
|
+
} from './locale-helpers.js';
|
|
14
|
+
|
|
15
|
+
export { normalizeLanguageList };
|
|
9
16
|
|
|
10
17
|
export interface EmbeddingChunk {
|
|
11
18
|
/** Unique ID: `${pageSlug}#${sectionIndex}` */
|
|
@@ -25,6 +32,9 @@ export interface EmbeddingChunk {
|
|
|
25
32
|
prefix: string;
|
|
26
33
|
/** Page title from frontmatter, or slug-derived fallback */
|
|
27
34
|
pageTitle: string;
|
|
35
|
+
/** Locale derived from i18n config + leading slug segment. `null` for
|
|
36
|
+
* single-language projects (no `i18n.languages` configured). */
|
|
37
|
+
locale: string | null;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -200,16 +210,21 @@ function sanitizeHeadingText(raw: string): string {
|
|
|
200
210
|
* Chunk a documentation page into embedding-sized pieces.
|
|
201
211
|
*
|
|
202
212
|
* @param page - Page with file path, raw MDX content, and frontmatter
|
|
203
|
-
* @param
|
|
213
|
+
* @param options - maxChars cap (default 2000, ~500 tokens) and i18n languages
|
|
214
|
+
* (used to derive each chunk's locale tag)
|
|
204
215
|
* @returns Array of embedding chunks with unique IDs
|
|
205
216
|
*/
|
|
206
217
|
export function chunkPageForEmbedding(
|
|
207
218
|
page: { path: string; content: string; frontmatter: Record<string, unknown> },
|
|
208
|
-
maxChars =
|
|
219
|
+
options: { maxChars?: number; languages?: LanguageEntry[] } = {},
|
|
209
220
|
): EmbeddingChunk[] {
|
|
221
|
+
const maxChars = options.maxChars ?? 2000;
|
|
222
|
+
const languages = options.languages ?? [];
|
|
223
|
+
|
|
210
224
|
const slug = page.path.replace(/\.mdx?$/, '').replace(/\\/g, '/');
|
|
211
225
|
const rawTitle = (page.frontmatter.title as string) || titleFromSlug(slug);
|
|
212
226
|
const pageTitle = sanitizeHeadingText(rawTitle) || titleFromSlug(slug);
|
|
227
|
+
const locale = deriveChunkLocale(page.path, languages);
|
|
213
228
|
|
|
214
229
|
// Normalize Windows line endings before extracting sections
|
|
215
230
|
const normalizedContent = preprocessUpdateBlocks(page.content.replace(/\r\n/g, '\n'));
|
|
@@ -238,6 +253,7 @@ export function chunkPageForEmbedding(
|
|
|
238
253
|
content: piece,
|
|
239
254
|
prefix,
|
|
240
255
|
pageTitle,
|
|
256
|
+
locale,
|
|
241
257
|
});
|
|
242
258
|
chunkIndex++;
|
|
243
259
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[
|
|
2
|
+
"en",
|
|
3
|
+
"cn", "zh", "zh-Hans", "zh-Hant",
|
|
4
|
+
"es",
|
|
5
|
+
"fr", "fr-CA", "fr-ca",
|
|
6
|
+
"ja", "jp", "ja-jp",
|
|
7
|
+
"pt", "pt-BR",
|
|
8
|
+
"de",
|
|
9
|
+
"ko",
|
|
10
|
+
"it",
|
|
11
|
+
"ru",
|
|
12
|
+
"ro",
|
|
13
|
+
"cs",
|
|
14
|
+
"id",
|
|
15
|
+
"ar",
|
|
16
|
+
"tr",
|
|
17
|
+
"hi",
|
|
18
|
+
"sv",
|
|
19
|
+
"no",
|
|
20
|
+
"lv",
|
|
21
|
+
"nl",
|
|
22
|
+
"uk",
|
|
23
|
+
"vi",
|
|
24
|
+
"pl",
|
|
25
|
+
"uz",
|
|
26
|
+
"he"
|
|
27
|
+
]
|