Skip to content

Commit 4d010fe

Browse files
ouiliameclaude
andcommitted
docs: address Greptile + Cursor review on Ask AI
- Guard req.json() with try/catch → 400 on malformed body (was 500) - Scope vector search to the reader's locale (mirrors site search); client forwards the active locale to the route - Backstop the whole serialized payload so assistant/tool parts can't be stuffed past the user-text cap - Split the scroll effect: instant jump on panel open, smooth on new messages - Add rel="noopener noreferrer" target="_blank" to source-chip links Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7e78c20 commit 4d010fe

3 files changed

Lines changed: 70 additions & 18 deletions

File tree

apps/docs/app/[lang]/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default async function Layout({ children, params }: LayoutProps) {
121121
>
122122
{children}
123123
</DocsLayout>
124-
<AskAI />
124+
<AskAI locale={lang} />
125125
</RootProvider>
126126
</body>
127127
</html>

apps/docs/app/api/chat/route.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ const CHAT_MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-5.4-mini'
1414
/** Max documentation chunks returned per search to ground an answer. */
1515
const SEARCH_LIMIT = 6
1616

17+
/** Candidates pulled before locale filtering, so a locale still yields SEARCH_LIMIT results. */
18+
const SEARCH_CANDIDATES = SEARCH_LIMIT * 4
19+
20+
/** Locales the docs are published in (mirrors the site search route). */
21+
const KNOWN_LOCALES = ['en', 'es', 'fr', 'de', 'ja', 'zh']
22+
const DEFAULT_LOCALE = 'en'
23+
24+
/**
25+
* Match a chunk's source document to a locale, mirroring the site search route:
26+
* non-English docs are prefixed with their locale segment; unprefixed docs are
27+
* English.
28+
*/
29+
function matchesLocale(sourceDocument: string, locale: string): boolean {
30+
const firstSegment = sourceDocument.split('/')[0]
31+
if (KNOWN_LOCALES.includes(firstSegment)) return firstSegment === locale
32+
return locale === DEFAULT_LOCALE
33+
}
34+
1735
/**
1836
* Abuse guards. This endpoint proxies a paid LLM, so an unauthenticated public
1937
* route is a target for scripted "free inference". These bounds cap the cost of
@@ -28,6 +46,8 @@ const MAX_MESSAGES = 200
2846
const MAX_USER_INPUT_CHARS = 400_000
2947
const MAX_OUTPUT_TOKENS = 4000
3048
const MAX_STEPS = 6
49+
/** Backstop on the whole serialized payload — blocks stuffing assistant/tool parts past the user-text cap. */
50+
const MAX_TOTAL_CHARS = 1_000_000
3151

3252
/** Total length of user-authored text across the conversation. */
3353
function userInputChars(messages: UIMessage[]): number {
@@ -84,7 +104,7 @@ Guidelines:
84104
* Vector search over the docs embeddings, returning the most relevant chunks
85105
* with their source links so the model can ground and cite its answer.
86106
*/
87-
async function searchDocs(query: string) {
107+
async function searchDocs(query: string, locale: string) {
88108
const embedding = await generateSearchEmbedding(query)
89109
const vectorLiteral = JSON.stringify(embedding)
90110

@@ -93,32 +113,47 @@ async function searchDocs(query: string) {
93113
title: docsEmbeddings.headerText,
94114
url: docsEmbeddings.sourceLink,
95115
content: docsEmbeddings.chunkText,
96-
similarity: sql<number>`1 - (${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector)`,
116+
sourceDocument: docsEmbeddings.sourceDocument,
97117
})
98118
.from(docsEmbeddings)
99119
.orderBy(sql`${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector`)
100-
.limit(SEARCH_LIMIT)
101-
102-
return rows.map((row) => ({
103-
title: row.title,
104-
url: row.url,
105-
content: row.content,
106-
}))
120+
.limit(SEARCH_CANDIDATES)
121+
122+
return rows
123+
.filter((row) => matchesLocale(row.sourceDocument, locale))
124+
.slice(0, SEARCH_LIMIT)
125+
.map((row) => ({
126+
title: row.title,
127+
url: row.url,
128+
content: row.content,
129+
}))
107130
}
108131

109132
export async function POST(req: Request) {
110133
if (!isAllowedOrigin(req)) {
111134
return new Response('Forbidden', { status: 403 })
112135
}
113136

114-
const { messages }: { messages: UIMessage[] } = await req.json()
137+
let body: { messages: UIMessage[]; locale?: string }
138+
try {
139+
body = await req.json()
140+
} catch {
141+
return new Response('Invalid JSON', { status: 400 })
142+
}
143+
const { messages } = body
144+
const locale = KNOWN_LOCALES.includes(body.locale ?? '')
145+
? (body.locale as string)
146+
: DEFAULT_LOCALE
115147

116148
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) {
117149
return new Response('Invalid request', { status: 400 })
118150
}
119151
if (userInputChars(messages) > MAX_USER_INPUT_CHARS) {
120152
return new Response('Request too large', { status: 413 })
121153
}
154+
if (JSON.stringify(messages).length > MAX_TOTAL_CHARS) {
155+
return new Response('Request too large', { status: 413 })
156+
}
122157

123158
const result = streamText({
124159
model: openai(CHAT_MODEL),
@@ -133,7 +168,7 @@ export async function POST(req: Request) {
133168
inputSchema: z.object({
134169
query: z.string().describe('A focused natural-language search query.'),
135170
}),
136-
execute: async ({ query }) => searchDocs(query),
171+
execute: async ({ query }) => searchDocs(query, locale),
137172
}),
138173
},
139174
})

apps/docs/components/ai/ask-ai.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type FormEvent, useEffect, useRef, useState } from 'react'
3+
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
44
import { useChat } from '@ai-sdk/react'
55
import { DefaultChatTransport } from 'ai'
66
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
@@ -40,20 +40,35 @@ function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>)
4040
.join('')
4141
}
4242

43-
export function AskAI() {
43+
interface AskAIProps {
44+
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
45+
locale: string
46+
}
47+
48+
export function AskAI({ locale }: AskAIProps) {
4449
const [open, setOpen] = useState(false)
4550
const [input, setInput] = useState('')
4651
const scrollRef = useRef<HTMLDivElement>(null)
4752

48-
const { messages, sendMessage, status, stop, error } = useChat({
49-
transport: new DefaultChatTransport({ api: '/api/chat' }),
50-
})
53+
const transport = useMemo(
54+
() => new DefaultChatTransport({ api: '/api/chat', body: { locale } }),
55+
[locale]
56+
)
57+
58+
const { messages, sendMessage, status, stop, error } = useChat({ transport })
5159

5260
const isBusy = status === 'submitted' || status === 'streaming'
5361

62+
// Jump to the bottom instantly when the panel opens (a mount transition).
63+
useEffect(() => {
64+
if (!open) return
65+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
66+
}, [open])
67+
68+
// Smooth-scroll as new messages stream in (an explicit re-orientation cue).
5469
useEffect(() => {
5570
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
56-
}, [messages, open])
71+
}, [messages])
5772

5873
const handleSubmit = (event: FormEvent) => {
5974
event.preventDefault()
@@ -133,6 +148,8 @@ export function AskAI() {
133148
<a
134149
key={source.url}
135150
href={source.url}
151+
target='_blank'
152+
rel='noopener noreferrer'
136153
className='rounded-md border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
137154
>
138155
{source.title || source.url}

0 commit comments

Comments
 (0)