Skip to content

Commit d62e861

Browse files
ouiliameclaude
andcommitted
docs: Ask AI chat grounded in the docs vector store
Adds an "Ask AI" chat to the docs site. A floating launcher opens a panel backed by the Vercel AI SDK (OpenAI provider, OPENAI_API_KEY from the environment). The chat is grounded via a searchDocs tool that runs a vector search over the existing docs embeddings and returns source links, so answers cite real pages. - app/api/chat/route.ts — streaming POST handler (streamText + searchDocs tool) - components/ai/ask-ai.tsx — useChat panel with streamed answers + source chips - wired into the docs layout; reuses the existing OPENAI_API_KEY and embeddings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e96b150 commit d62e861

5 files changed

Lines changed: 280 additions & 1 deletion

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
33
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
44
import { RootProvider } from 'fumadocs-ui/provider/next'
55
import { Geist_Mono, Inter } from 'next/font/google'
6+
import { AskAI } from '@/components/ai/ask-ai'
67
import {
78
SidebarFolder,
89
SidebarItem,
@@ -120,6 +121,7 @@ export default async function Layout({ children, params }: LayoutProps) {
120121
>
121122
{children}
122123
</DocsLayout>
124+
<AskAI />
123125
</RootProvider>
124126
</body>
125127
</html>

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { openai } from '@ai-sdk/openai'
2+
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai'
3+
import { sql } from 'drizzle-orm'
4+
import { z } from 'zod'
5+
import { db, docsEmbeddings } from '@/lib/db'
6+
import { generateSearchEmbedding } from '@/lib/embeddings'
7+
8+
export const runtime = 'nodejs'
9+
export const maxDuration = 30
10+
11+
/** Model used for the Ask AI chat. Override with OPENAI_CHAT_MODEL in the environment. */
12+
const CHAT_MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini'
13+
14+
/** Max documentation chunks returned per search to ground an answer. */
15+
const SEARCH_LIMIT = 6
16+
17+
const SYSTEM_PROMPT = `You are the documentation assistant for Sim — the open-source AI workspace where teams build, deploy, and manage AI agents.
18+
19+
Answer questions about Sim using the documentation. Always call the searchDocs tool before answering anything specific about Sim's features, configuration, or usage — do not answer from memory. Base your answer only on the returned documentation; if the docs do not cover the question, say so plainly rather than guessing.
20+
21+
Guidelines:
22+
- Be direct and concrete. Lead with the answer, then the detail.
23+
- Reference the relevant pages by their titles so the user knows where to read more.
24+
- When you show configuration or code, keep it minimal and correct.
25+
- The agent is called "Sim" and the chat surface is "Chat" — never say "Mothership" or "copilot".
26+
- If a question is unrelated to Sim, briefly say it's outside the docs' scope.`
27+
28+
/**
29+
* Vector search over the docs embeddings, returning the most relevant chunks
30+
* with their source links so the model can ground and cite its answer.
31+
*/
32+
async function searchDocs(query: string) {
33+
const embedding = await generateSearchEmbedding(query)
34+
const vectorLiteral = JSON.stringify(embedding)
35+
36+
const rows = await db
37+
.select({
38+
title: docsEmbeddings.headerText,
39+
url: docsEmbeddings.sourceLink,
40+
content: docsEmbeddings.chunkText,
41+
similarity: sql<number>`1 - (${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector)`,
42+
})
43+
.from(docsEmbeddings)
44+
.orderBy(sql`${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector`)
45+
.limit(SEARCH_LIMIT)
46+
47+
return rows.map((row) => ({
48+
title: row.title,
49+
url: row.url,
50+
content: row.content,
51+
}))
52+
}
53+
54+
export async function POST(req: Request) {
55+
const { messages }: { messages: UIMessage[] } = await req.json()
56+
57+
const result = streamText({
58+
model: openai(CHAT_MODEL),
59+
system: SYSTEM_PROMPT,
60+
messages: convertToModelMessages(messages),
61+
stopWhen: stepCountIs(5),
62+
tools: {
63+
searchDocs: tool({
64+
description:
65+
'Search the Sim documentation for relevant content. Use this before answering any question about Sim.',
66+
inputSchema: z.object({
67+
query: z.string().describe('A focused natural-language search query.'),
68+
}),
69+
execute: async ({ query }) => searchDocs(query),
70+
}),
71+
},
72+
})
73+
74+
return result.toUIMessageStreamResponse()
75+
}

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

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
'use client'
2+
3+
import { type FormEvent, useEffect, useRef, useState } from 'react'
4+
import { useChat } from '@ai-sdk/react'
5+
import { DefaultChatTransport } from 'ai'
6+
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
7+
import { cn } from '@/lib/utils'
8+
9+
interface DocSource {
10+
title: string
11+
url: string
12+
}
13+
14+
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
15+
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
16+
const seen = new Set<string>()
17+
const sources: DocSource[] = []
18+
19+
for (const part of parts) {
20+
if (part.type !== 'tool-searchDocs') continue
21+
const output = (part as { output?: unknown }).output
22+
if (!Array.isArray(output)) continue
23+
for (const item of output as DocSource[]) {
24+
if (!item?.url || seen.has(item.url)) continue
25+
seen.add(item.url)
26+
sources.push({ title: item.title, url: item.url })
27+
}
28+
}
29+
30+
return sources
31+
}
32+
33+
/** Concatenate the streamed text parts of a message. */
34+
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
35+
return parts
36+
.filter((part) => part.type === 'text')
37+
.map((part) => (part as unknown as { text: string }).text)
38+
.join('')
39+
}
40+
41+
export function AskAI() {
42+
const [open, setOpen] = useState(false)
43+
const [input, setInput] = useState('')
44+
const scrollRef = useRef<HTMLDivElement>(null)
45+
46+
const { messages, sendMessage, status, stop, error } = useChat({
47+
transport: new DefaultChatTransport({ api: '/api/chat' }),
48+
})
49+
50+
const isBusy = status === 'submitted' || status === 'streaming'
51+
52+
useEffect(() => {
53+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
54+
}, [messages, open])
55+
56+
const handleSubmit = (event: FormEvent) => {
57+
event.preventDefault()
58+
const text = input.trim()
59+
if (!text || isBusy) return
60+
sendMessage({ text })
61+
setInput('')
62+
}
63+
64+
return (
65+
<>
66+
{!open && (
67+
<button
68+
type='button'
69+
aria-label='Ask AI'
70+
onClick={() => setOpen(true)}
71+
className='fixed right-4 bottom-4 z-50 flex h-11 items-center gap-2 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-4 font-season text-[var(--text-base)] text-sm shadow-lg transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
72+
>
73+
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
74+
Ask AI
75+
</button>
76+
)}
77+
78+
{open && (
79+
<div className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-xl dark:bg-[var(--surface-4)]'>
80+
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
81+
<span className='flex items-center gap-2 font-season text-[var(--text-base)] text-sm'>
82+
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
83+
Ask AI
84+
</span>
85+
<button
86+
type='button'
87+
aria-label='Close'
88+
onClick={() => setOpen(false)}
89+
className='flex size-7 items-center justify-center rounded-md text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
90+
>
91+
<X className='size-[16px]' />
92+
</button>
93+
</div>
94+
95+
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
96+
{messages.length === 0 && (
97+
<p className='text-[var(--text-muted)] text-sm'>
98+
Ask anything about building, deploying, and managing AI agents in Sim.
99+
</p>
100+
)}
101+
102+
{messages.map((message) => {
103+
const text = getText(message.parts)
104+
const sources = message.role === 'assistant' ? getSources(message.parts) : []
105+
return (
106+
<div
107+
key={message.id}
108+
className={cn(
109+
'flex flex-col gap-1',
110+
message.role === 'user' ? 'items-end' : 'items-start'
111+
)}
112+
>
113+
<div
114+
className={cn(
115+
'max-w-[90%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm',
116+
message.role === 'user'
117+
? 'bg-[var(--surface-active)] text-[var(--text-base)]'
118+
: 'text-[var(--text-base)]'
119+
)}
120+
>
121+
{text || (isBusy ? '…' : '')}
122+
</div>
123+
{sources.length > 0 && (
124+
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
125+
{sources.map((source) => (
126+
<a
127+
key={source.url}
128+
href={source.url}
129+
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)]'
130+
>
131+
{source.title || source.url}
132+
</a>
133+
))}
134+
</div>
135+
)}
136+
</div>
137+
)
138+
})}
139+
140+
{error && (
141+
<p className='text-[var(--text-muted)] text-sm'>
142+
Something went wrong. Please try again.
143+
</p>
144+
)}
145+
</div>
146+
147+
<form
148+
onSubmit={handleSubmit}
149+
className='flex items-end gap-2 border-[var(--border-1)] border-t px-3 py-3'
150+
>
151+
<textarea
152+
value={input}
153+
onChange={(event) => setInput(event.target.value)}
154+
onKeyDown={(event) => {
155+
if (event.key === 'Enter' && !event.shiftKey) {
156+
event.preventDefault()
157+
handleSubmit(event)
158+
}
159+
}}
160+
rows={1}
161+
placeholder='Ask a question…'
162+
className='max-h-32 flex-1 resize-none bg-transparent font-season text-[var(--text-base)] text-sm outline-none placeholder:text-[var(--text-muted)]'
163+
/>
164+
{isBusy ? (
165+
<button
166+
type='button'
167+
aria-label='Stop'
168+
onClick={() => stop()}
169+
className='flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-active)] text-[var(--text-icon)]'
170+
>
171+
<Square className='size-[14px]' />
172+
</button>
173+
) : (
174+
<button
175+
type='submit'
176+
aria-label='Send'
177+
disabled={!input.trim()}
178+
className='flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--text-base)] text-[var(--surface-5)] transition-opacity disabled:opacity-40 dark:bg-[var(--text-base)]'
179+
>
180+
<ArrowUp className='size-[16px]' />
181+
</button>
182+
)}
183+
</form>
184+
</div>
185+
)}
186+
</>
187+
)
188+
}

apps/docs/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
"format:check": "biome format ."
1616
},
1717
"dependencies": {
18+
"@ai-sdk/openai": "2.0.107",
19+
"@ai-sdk/react": "2.0.205",
1820
"@sim/db": "workspace:*",
1921
"@vercel/og": "^0.6.5",
22+
"ai": "5.0.203",
2023
"class-variance-authority": "^0.7.1",
2124
"clsx": "^2.1.1",
2225
"drizzle-orm": "^0.45.2",
@@ -33,7 +36,8 @@
3336
"shiki": "4.0.0",
3437
"tailwind-merge": "^3.0.2",
3538
"reactflow": "^11.11.4",
36-
"framer-motion": "^12.5.0"
39+
"framer-motion": "^12.5.0",
40+
"zod": "^4.3.6"
3741
},
3842
"devDependencies": {
3943
"@sim/tsconfig": "workspace:*",

bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)