diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 249d8d8a1ae..2d9e00a8849 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -39,6 +39,21 @@ function resolveLangAndSlug(params: { slug?: string[]; lang: string }) { return { lang, slug } } +/** + * Strips a leading `/{lang}` path segment from a page URL. Unlike a naive + * `String.replace`, this only removes the locale when it is actually the + * first path segment — a plain substring replace would also match `/en` + * inside unrelated slugs (e.g. `/platform/enterprise`, `/integrations/enrich`, + * `/platform/self-hosting/environment-variables`), corrupting canonical and + * hreflang URLs for those pages. + */ +function stripLocalePrefix(url: string, lang: string): string { + const prefix = `/${lang}` + if (url === prefix) return '' + if (url.startsWith(`${prefix}/`)) return url.slice(prefix.length) + return url +} + const APIPage = createAPIPage(openapi, { playground: { enabled: false }, content: { @@ -342,13 +357,13 @@ export async function generateMetadata(props: { alternates: { canonical: fullUrl, languages: { - 'x-default': `${BASE_URL}${page.url.replace(`/${lang}`, '')}`, - en: `${BASE_URL}${page.url.replace(`/${lang}`, '')}`, - es: `${BASE_URL}/es${page.url.replace(`/${lang}`, '')}`, - fr: `${BASE_URL}/fr${page.url.replace(`/${lang}`, '')}`, - de: `${BASE_URL}/de${page.url.replace(`/${lang}`, '')}`, - ja: `${BASE_URL}/ja${page.url.replace(`/${lang}`, '')}`, - zh: `${BASE_URL}/zh${page.url.replace(`/${lang}`, '')}`, + 'x-default': `${BASE_URL}${stripLocalePrefix(page.url, lang)}`, + en: `${BASE_URL}${stripLocalePrefix(page.url, lang)}`, + es: `${BASE_URL}/es${stripLocalePrefix(page.url, lang)}`, + fr: `${BASE_URL}/fr${stripLocalePrefix(page.url, lang)}`, + de: `${BASE_URL}/de${stripLocalePrefix(page.url, lang)}`, + ja: `${BASE_URL}/ja${stripLocalePrefix(page.url, lang)}`, + zh: `${BASE_URL}/zh${stripLocalePrefix(page.url, lang)}`, }, }, } diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index f7dadc59afb..4fb73ecee32 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -110,6 +110,7 @@ export default async function Layout({ children, params }: LayoutProps) { collapsible: false, footer: null, banner: null, + prefetch: false, components: { Item: SidebarItem, Folder: SidebarFolder, diff --git a/apps/docs/components/ai/ask-ai-panel.tsx b/apps/docs/components/ai/ask-ai-panel.tsx new file mode 100644 index 00000000000..9d41e68d1b8 --- /dev/null +++ b/apps/docs/components/ai/ask-ai-panel.tsx @@ -0,0 +1,234 @@ +'use client' + +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import { ArrowUp, MessageCircle, Square, X } from 'lucide-react' +import { Streamdown } from 'streamdown' +import { cn } from '@/lib/utils' +import 'streamdown/styles.css' + +interface DocSource { + title: string + url: string +} + +/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */ +function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] { + const seen = new Set() + const sources: DocSource[] = [] + + for (const part of parts) { + if (part.type !== 'tool-searchDocs') continue + const output = (part as { output?: unknown }).output + if (!Array.isArray(output)) continue + for (const item of output as DocSource[]) { + if (!item?.url || seen.has(item.url)) continue + seen.add(item.url) + sources.push({ title: item.title, url: item.url }) + } + } + + return sources +} + +/** Concatenate the streamed text parts of a message. */ +function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string { + return parts + .filter((part) => part.type === 'text') + .map((part) => (part as unknown as { text: string }).text) + .join('') +} + +interface AskAIPanelProps { + /** Active docs locale, forwarded so retrieval is scoped to the reader's language. */ + locale: string + open: boolean + onClose: () => void +} + +export function AskAIPanel({ locale, open, onClose }: AskAIPanelProps) { + const [input, setInput] = useState('') + const scrollRef = useRef(null) + const textareaRef = useRef(null) + + const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), []) + + const { messages, sendMessage, status, stop, error } = useChat({ transport }) + + const isBusy = status === 'submitted' || status === 'streaming' + + const handleClose = () => { + stop() + onClose() + } + + useEffect(() => { + if (!open) return + textareaRef.current?.focus() + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open]) + + useEffect(() => { + if (!open) return + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }, [open]) + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }) + }, [messages]) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + const text = input.trim() + if (!text || isBusy) return + sendMessage({ text }, { body: { locale } }) + setInput('') + } + + if (!open) return null + + return ( +
+
+ + + Ask Sim + + +
+ +
+ {messages.length === 0 && ( +

+ Ask anything about building, deploying, and managing AI agents in Sim. +

+ )} + + {messages.map((message, index) => { + const text = getText(message.parts) + const isStreaming = isBusy && index === messages.length - 1 + const sources = message.role === 'assistant' ? getSources(message.parts) : [] + return ( +
+ {message.role === 'user' ? ( +
+ {text} +
+ ) : ( +
+ {text ? ( + + {text} + + ) : isStreaming ? ( + '…' + ) : sources.length === 0 ? ( + No answer returned. + ) : null} +
+ )} + {sources.length > 0 && ( +
+ {sources.map((source) => ( + + {source.title || source.url} + + ))} +
+ )} +
+ ) + })} + + {error && ( +

+ Something went wrong. Please try again. +

+ )} +
+ +
+
+