From 7f0cce3bdadf95bc683ca44eaf887b62bc8819c0 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 25 Sep 2026 19:17:35 +0400 Subject: [PATCH 1/5] feat(docs): add AI assistant --- .env.example | 2 + apps/web/package.json | 2 + apps/web/src/app/api/chat/route.ts | 73 +++ apps/web/src/app/api/search/route.ts | 6 +- .../src/components/docs/docs-assistant.tsx | 513 ++++++++++++++++++ apps/web/src/components/docs/docs-layout.tsx | 50 +- apps/web/src/lib/docs-search.ts | 5 + pnpm-lock.yaml | 143 ++++- 8 files changed, 778 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/api/chat/route.ts create mode 100644 apps/web/src/components/docs/docs-assistant.tsx create mode 100644 apps/web/src/lib/docs-search.ts diff --git a/.env.example b/.env.example index 55086b1b..9194b53a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ E2E_STRIPE_WHSEC= # web RESEND_API_KEY= GITHUB_SPONSORS_TOKEN= +AI_GATEWAY_API_KEY= +AI_GATEWAY_MODEL=openai/gpt-5.6-luna # demo APP_URL=http://localhost:3000 diff --git a/apps/web/package.json b/apps/web/package.json index a57eafba..7ca5a8d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ai-sdk/react": "4.0.110", "@base-ui/react": "^1.8.0", "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.10", @@ -25,6 +26,7 @@ "@types/mdx": "^2.0.14", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^2.0.0", + "ai": "7.0.107", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts new file mode 100644 index 00000000..10ca696a --- /dev/null +++ b/apps/web/src/app/api/chat/route.ts @@ -0,0 +1,73 @@ +import { + convertToModelMessages, + createUIMessageStreamResponse, + stepCountIs, + streamText, + tool, + toUIMessageStream, + type UIMessage, +} from "ai"; +import { z } from "zod"; + +import { docsSearch } from "@/lib/docs-search"; +import { source } from "@/lib/source"; + +const systemPrompt = [ + "You are the PayKit documentation assistant.", + "Answer questions about PayKit using the search tool before answering.", + "Ground answers in the search results and cite relevant pages as Markdown links using their url field.", + "Be concise and practical. If the documentation does not answer the question, say so.", +].join("\n"); + +const search = tool({ + description: "Search the PayKit documentation for information relevant to the user's question.", + inputSchema: z.object({ + query: z.string().min(1), + limit: z.number().int().min(1).max(10).default(6), + }), + async execute({ query, limit }) { + const results = await docsSearch.search(query, { limit: limit * 4 }); + const urls = [ + ...new Set( + results.flatMap((result) => { + const url = result.url.split("#", 1)[0]; + return url ? [url] : []; + }), + ), + ].slice(0, limit); + + return Promise.all( + urls.map(async (url) => { + const page = source.getPageByUrl(url); + if (!page) return null; + + return { + content: await page.data.getText("processed"), + description: page.data.description ?? "", + title: page.data.title, + url: page.url, + }; + }), + ).then((pages) => pages.filter((page) => page !== null)); + }, +}); + +export async function POST(request: Request) { + const body = (await request.json()) as { currentPage?: string; messages?: UIMessage[] }; + const currentPage = body.currentPage?.startsWith("/docs") ? body.currentPage : undefined; + + const result = streamText({ + instructions: currentPage + ? `${systemPrompt}\nThe reader is viewing ${currentPage}.` + : systemPrompt, + model: process.env.AI_GATEWAY_MODEL ?? "openai/gpt-5.6-luna", + messages: await convertToModelMessages(body.messages ?? []), + stopWhen: stepCountIs(4), + toolChoice: "auto", + tools: { search }, + }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ stream: result.stream }), + }); +} diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index 84ede847..bd6db04d 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,5 +1,3 @@ -import { createFromSource } from "fumadocs-core/search/server"; +import { docsSearch } from "@/lib/docs-search"; -import { source } from "@/lib/source"; - -export const { GET } = createFromSource(source); +export const { GET } = docsSearch; diff --git a/apps/web/src/components/docs/docs-assistant.tsx b/apps/web/src/components/docs/docs-assistant.tsx new file mode 100644 index 00000000..a7c8113d --- /dev/null +++ b/apps/web/src/components/docs/docs-assistant.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport, type UIMessage } from "ai"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ComponentPropsWithoutRef, FormEvent, KeyboardEvent } from "react"; +import { Children, isValidElement, useEffect, useMemo, useRef, useState } from "react"; +import { + RiChat3Fill, + RiCloseLine, + RiLoader4Line, + RiRefreshLine, + RiRobot2Line, + RiSearchLine, + RiSendPlane2Line, +} from "react-icons/ri"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { DefaultPre } from "@/components/docs/package-command"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DynamicCodeBlock } from "@/components/ui/dynamic-code-block"; +import { cn } from "@/lib/utils"; + +const suggestions = [ + "How do I define plans and features?", + "How do entitlements work?", + "How should I report metered usage?", +]; + +type DocsChatState = ReturnType; + +function MarkdownLink({ href, children, ...props }: ComponentPropsWithoutRef<"a">) { + if (href?.startsWith("/docs")) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function AssistantPre({ children }: ComponentPropsWithoutRef<"pre">) { + const child = Children.toArray(children)[0]; + if ( + !isValidElement<{ children?: unknown; className?: string }>(child) || + typeof child.props.children !== "string" + ) { + return ( +
+ {children} +
+ ); + } + + const language = + child.props.className + ?.split(" ") + .find((value) => value.startsWith("language-")) + ?.slice("language-".length) ?? "text"; + + return ( +
+ +
+ ); +} + +function AssistantMarkdown({ children }: { children: string }) { + return ( + ( +
{quote}
+ ), + code: ({ className, children: code, ...props }) => + className?.startsWith("language-") ? ( + + {code} + + ) : ( + + {code} + + ), + h1: ({ children: heading }) => ( +

{heading}

+ ), + h2: ({ children: heading }) => ( +

{heading}

+ ), + h3: ({ children: heading }) => ( +

{heading}

+ ), + li: ({ children: item }) =>
  • {item}
  • , + ol: ({ children: list }) =>
      {list}
    , + p: ({ children: paragraph }) => ( +

    {paragraph}

    + ), + pre: AssistantPre, + table: ({ children: table }) => ( +
    + {table}
    +
    + ), + td: ({ children: cell }) => {cell}, + th: ({ children: cell }) => {cell}, + ul: ({ children: list }) =>
      {list}
    , + }} + > + {children} +
    + ); +} + +function getMessageText(message: UIMessage) { + return message.parts + .filter( + (part): part is Extract<(typeof message.parts)[number], { type: "text" }> => + part.type === "text", + ) + .map((part) => part.text) + .join(""); +} + +function getSearchResultCount(output: unknown) { + return Array.isArray(output) ? output.length : undefined; +} + +function getSearchStates(message: UIMessage) { + const seenQueries = new Set(); + + return message.parts.flatMap((part) => { + if (part.type !== "tool-search" || typeof part !== "object") return []; + + const record = part as unknown as Record; + const input = record.input as Record | undefined; + const query = typeof input?.query === "string" ? input.query : "search"; + if (seenQueries.has(query)) return []; + seenQueries.add(query); + + const state = typeof record.state === "string" ? record.state : ""; + const failed = state === "output-error" || state === "output-denied"; + const complete = state === "output-available"; + const resultCount = complete ? getSearchResultCount(record.output) : undefined; + + return [ + { + failed, + key: typeof record.toolCallId === "string" ? record.toolCallId : query, + label: failed + ? "Failed to search documentation" + : complete + ? resultCount === undefined + ? "Searched PayKit docs" + : `${resultCount} search results` + : "Searching…", + }, + ]; + }); +} + +function normalizeDocsPath(value: unknown) { + if (typeof value !== "string") return undefined; + const pathname = value.split(/[?#]/, 1)[0]; + return pathname === "/docs" || pathname?.startsWith("/docs/") ? pathname : undefined; +} + +function getReferences(markdown: string) { + const references = new Map(); + + const linkPattern = /\[([^\]]+)]\((\/docs(?:\/[^\s)#?]+)?)(?:[?#][^)]*)?\)/g; + for (const match of markdown.matchAll(linkPattern)) { + const url = normalizeDocsPath(match[2]); + if (url && !references.has(url)) references.set(url, { title: match[1]!, url }); + } + + return [...references.values()]; +} + +function AssistantMessage({ message }: { message: UIMessage }) { + const text = getMessageText(message); + const searchStates = getSearchStates(message); + const references = text ? getReferences(text) : []; + + if (message.role === "assistant" && !text && searchStates.length === 0) return null; + + return ( +
    +

    + {message.role === "assistant" ? "PayKit" : "You"} +

    + {text ? ( +
    + {text} +
    + ) : null} + {searchStates.map((tool) => ( +
    + {tool.failed ? ( + + ) : ( + + )} + {tool.label} +
    + ))} + {references.length > 0 ? ( +
    + {references.map((reference, index) => ( + +

    {reference.title}

    +

    Reference {index + 1}

    + + ))} +
    + ) : null} +
    + ); +} + +function ChatPanel({ chat, onClose }: { chat: DocsChatState; onClose: () => void }) { + const [input, setInput] = useState(""); + const listRef = useRef(null); + const textareaRef = useRef(null); + const followOutputRef = useRef(true); + const { error, messages, regenerate, sendMessage, setMessages, status, stop } = chat; + const busy = status === "streaming" || status === "submitted"; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = "0px"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 128)}px`; + }, [input]); + + useEffect(() => { + if (!followOutputRef.current) return; + const frame = requestAnimationFrame(() => { + const list = listRef.current; + if (list) list.scrollTop = list.scrollHeight; + }); + return () => cancelAnimationFrame(frame); + }, [messages, status]); + + function submitMessage(value: string) { + const text = value.trim(); + if (!text || busy) return; + followOutputRef.current = true; + void sendMessage({ role: "user", parts: [{ type: "text", text }] }); + setInput(""); + } + + function onSubmit(event: FormEvent) { + event.preventDefault(); + submitMessage(input); + } + + function onComposerKeyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submitMessage(input); + } + } + + return ( +
    +
    +
    +

    AI Assistant

    +

    + Powered by{" "} + + Fumadocs + +

    +
    + +
    + +
    { + const element = event.currentTarget; + followOutputRef.current = + element.scrollHeight - element.scrollTop - element.clientHeight < 80; + }} + > + {messages.length === 0 ? ( +
    + +

    Start a new chat below, or choose a question.

    +
    + {suggestions.map((suggestion) => ( + + ))} +
    +
    + ) : ( +
    + {messages + .filter((message) => message.role !== "system") + .map((message) => ( + + ))} + {error ? ( +
    + {error.message || "The request failed."} +
    + ) : null} +
    + )} +
    + +
    +
    +