Skip to content

Commit 54a38f9

Browse files
committed
feat: add Ask AI docs chatbot powered by OpenRouter
Floating 'Ask AI' button on every page opens a chat panel that searches the docs and answers questions using an LLM via OpenRouter. How it works: 1. User asks a question 2. LLM calls a `search` tool that does full-text search across all 290 MDX docs 3. Relevant doc chunks are returned as context 4. LLM generates a concise answer with links to the source pages Stack: - app/api/chat/route.ts — server route using Vercel AI SDK + OpenRouter provider - components/ai/AskAI.tsx — client chat UI using @ai-sdk/react useChat hook - Search index built from content/docs/ at startup (in-memory, no external DB) Config: - OPENROUTER_API_KEY — required, get one at https://openrouter.ai/keys - OPENROUTER_MODEL — optional, defaults to openai/gpt-5.4-nano ($0.20/1M in, $1.25/1M out) Dependencies added: ai, @ai-sdk/react, @openrouter/ai-sdk-provider
1 parent 4174c8d commit 54a38f9

6 files changed

Lines changed: 707 additions & 2 deletions

File tree

.env.template

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ OPENAI_API_KEY=
2828
SUPABASE_URL=
2929
# Supabase service role key for server-side access (optional, secret)
3030
SUPABASE_SERVICE_ROLE_KEY=
31+
32+
# Docs AI chat (OpenRouter)
33+
# OpenRouter API key — get one at https://openrouter.ai/keys (optional)
34+
OPENROUTER_API_KEY=
35+
# OpenRouter model ID (default: openai/gpt-5.4-nano)
36+
OPENROUTER_MODEL=
37+
3138
# Langfuse observability keys (optional)
3239
NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=
3340
LANGFUSE_PUBLIC_KEY=

app/api/chat/route.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
2+
import { streamText, tool, stepCountIs, convertToModelMessages } from 'ai'
3+
import { z } from 'zod'
4+
import { docsSource } from '@/lib/source'
5+
import { readFile } from 'node:fs/promises'
6+
import { join } from 'node:path'
7+
8+
interface SearchDoc {
9+
url: string
10+
title: string
11+
description: string
12+
content: string
13+
}
14+
15+
let searchIndex: SearchDoc[] | null = null
16+
17+
async function getSearchIndex(): Promise<SearchDoc[]> {
18+
if (searchIndex) return searchIndex
19+
20+
const pages = docsSource.getPages()
21+
const docs: SearchDoc[] = []
22+
23+
for (const page of pages) {
24+
try {
25+
const filePath = join(process.cwd(), 'content/docs', page.file.path)
26+
const raw = await readFile(filePath, 'utf-8')
27+
const content = raw.replace(/^---[\s\S]*?---\n*/, '')
28+
29+
docs.push({
30+
url: page.url,
31+
title: page.data.title,
32+
description: page.data.description ?? '',
33+
content: content.slice(0, 3000),
34+
})
35+
} catch {
36+
// Skip pages that can't be read
37+
}
38+
}
39+
40+
searchIndex = docs
41+
return docs
42+
}
43+
44+
function searchDocs(docs: SearchDoc[], query: string, limit: number): SearchDoc[] {
45+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
46+
47+
return docs
48+
.map((doc) => {
49+
const text = `${doc.title} ${doc.description} ${doc.content}`.toLowerCase()
50+
let score = 0
51+
for (const term of terms) {
52+
if (doc.title.toLowerCase().includes(term)) score += 10
53+
if (doc.description.toLowerCase().includes(term)) score += 5
54+
if (text.includes(term)) score += 1
55+
}
56+
return { doc, score }
57+
})
58+
.filter((r) => r.score > 0)
59+
.sort((a, b) => b.score - a.score)
60+
.slice(0, limit)
61+
.map((r) => r.doc)
62+
}
63+
64+
const openrouter = createOpenRouter({
65+
apiKey: process.env.OPENROUTER_API_KEY,
66+
})
67+
68+
const systemPrompt = `You are the LibreChat docs assistant. You help users find answers in the LibreChat documentation.
69+
70+
Rules:
71+
- ALWAYS use the \`search\` tool first before answering. Do not guess.
72+
- Be concise: 2-4 sentences max, then link to the relevant page.
73+
- Format answers in markdown. Use \`inline code\` for config keys, commands, filenames.
74+
- When referencing a doc page, link to it as: [Page Title](/docs/path) — always use the url from search results.
75+
- If a search result has a specific section heading relevant to the question, link to the anchor: [Section Name](/docs/path#section-name) where section-name is the heading lowercased with spaces replaced by hyphens.
76+
- If you cannot find the answer, say so honestly and suggest the user check the docs or ask on Discord.
77+
- Never invent features, config options, or CLI flags that don't appear in search results.
78+
- Prefer showing the exact config snippet or command over explaining it in prose.
79+
- When showing code blocks, specify the language (yaml, bash, env, etc).`
80+
81+
const searchTool = tool({
82+
description: 'Search the LibreChat documentation and return relevant pages.',
83+
inputSchema: z.object({
84+
query: z.string().describe('Search query'),
85+
limit: z.number().int().min(1).max(10).default(5),
86+
}),
87+
execute: async ({ query, limit }: { query: string; limit: number }) => {
88+
const docs = await getSearchIndex()
89+
const results = searchDocs(docs, query, limit)
90+
return results.map((r) => ({
91+
title: r.title,
92+
url: r.url,
93+
description: r.description,
94+
content: r.content.slice(0, 1500),
95+
}))
96+
},
97+
})
98+
99+
export async function POST(req: Request) {
100+
const { messages } = await req.json()
101+
102+
const modelMessages = await convertToModelMessages(messages)
103+
104+
const result = streamText({
105+
model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.4-nano'),
106+
stopWhen: stepCountIs(3),
107+
tools: { search: searchTool },
108+
system: systemPrompt,
109+
messages: modelMessages,
110+
toolChoice: 'auto',
111+
})
112+
113+
return result.toUIMessageStreamResponse()
114+
}

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SpeedInsights } from '@vercel/speed-insights/next'
66
import type { ReactNode } from 'react'
77
import type { Metadata } from 'next'
88
import { Provider } from '@/components/provider'
9+
import { AskAI } from '@/components/ai/AskAI'
910
import './global.css'
1011

1112
export const metadata: Metadata = {
@@ -42,6 +43,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
4243
>
4344
<body className="flex min-h-screen flex-col">
4445
<Provider>{children}</Provider>
46+
<AskAI />
4547
<Analytics />
4648
<SpeedInsights />
4749
{process.env.NEXT_PUBLIC_SCARF_PIXEL_ID && (

0 commit comments

Comments
 (0)