e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
import { getLLMText } from "@/lib/get-llm-text";
|
|
import { getDistinctId, posthogServer } from "@/lib/posthog-server";
|
|
import { createPrismTracer, prismAISDK } from "@/lib/prism-server";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { injectQuoteContext } from "@assistant-ui/react-ai-sdk";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { validateDocChatInput } from "@/lib/validate-input";
|
|
import { source, examples as examplesSource } from "@/lib/source";
|
|
import { getModel, withTracing } from "@/lib/ai/provider";
|
|
import { frontendTools } from "@assistant-ui/react-ai-sdk";
|
|
import { createBashTool } from "bash-tool";
|
|
import {
|
|
convertToModelMessages,
|
|
pruneMessages,
|
|
stepCountIs,
|
|
streamText,
|
|
tool,
|
|
zodSchema,
|
|
} from "ai";
|
|
import type * as PageTree from "fumadocs-core/page-tree";
|
|
import type { UIMessage } from "ai";
|
|
import z from "zod";
|
|
|
|
const SOURCE_SNAPSHOT_PATH = path.join(
|
|
process.cwd(),
|
|
"generated",
|
|
"source-snapshot.json",
|
|
);
|
|
|
|
function loadSourceSnapshot(): Record<string, string> {
|
|
try {
|
|
return JSON.parse(readFileSync(SOURCE_SNAPSHOT_PATH, "utf-8")) as Record<
|
|
string,
|
|
string
|
|
>;
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
error.code === "ENOENT"
|
|
) {
|
|
console.warn(
|
|
`Missing source snapshot at ${SOURCE_SNAPSHOT_PATH}; repo tools will be unavailable until generate:docs runs.`,
|
|
);
|
|
return {};
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const SOURCE_SNAPSHOT = loadSourceSnapshot();
|
|
|
|
function normalizeSegment(name: string): string {
|
|
return name.toLowerCase().replace(/\s+/g, "-");
|
|
}
|
|
|
|
function findFolderByPath(
|
|
tree: PageTree.Root,
|
|
path: string,
|
|
): PageTree.Folder | undefined {
|
|
const segments = path.split("/").filter(Boolean);
|
|
let currentFolder: PageTree.Folder | undefined;
|
|
let children: PageTree.Node[] = tree.children;
|
|
|
|
for (const segment of segments) {
|
|
const folder = children.find(
|
|
(node): node is PageTree.Folder =>
|
|
node.type === "folder" &&
|
|
normalizeSegment(typeof node.name === "string" ? node.name : "") ===
|
|
normalizeSegment(segment),
|
|
);
|
|
if (!folder) return undefined;
|
|
currentFolder = folder;
|
|
children = folder.children;
|
|
}
|
|
|
|
return currentFolder;
|
|
}
|
|
|
|
function listChildren(nodes: PageTree.Node[]) {
|
|
return nodes.flatMap((node) => {
|
|
const description =
|
|
"description" in node && typeof node.description === "string"
|
|
? node.description
|
|
: undefined;
|
|
|
|
switch (node.type) {
|
|
case "page":
|
|
return {
|
|
type: "page",
|
|
title: node.name,
|
|
url: node.url,
|
|
...(description ? { description } : {}),
|
|
};
|
|
case "folder":
|
|
return {
|
|
type: "folder",
|
|
name: node.name,
|
|
...(node.index ? { url: node.index.url } : {}),
|
|
...(description ? { description } : {}),
|
|
};
|
|
default:
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
const DOCS_PATH_ERROR = "Only local docs paths are supported";
|
|
|
|
function normalizeDocPath(slugOrUrl: string, routeUrl: string): string {
|
|
const raw = slugOrUrl.trim();
|
|
if (!raw) {
|
|
throw new Error("Slug/path is required");
|
|
}
|
|
|
|
const current = new URL(routeUrl);
|
|
const isAbsoluteUrl = /^https?:\/\//i.test(raw);
|
|
|
|
if (!isAbsoluteUrl) {
|
|
const cleaned = raw.replace(/^\/+/, "").replace(/^docs\//, "");
|
|
if (!cleaned || cleaned.includes("..")) {
|
|
throw new Error(DOCS_PATH_ERROR);
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
const resolved = new URL(raw);
|
|
if (resolved.origin !== current.origin) {
|
|
throw new Error(DOCS_PATH_ERROR);
|
|
}
|
|
|
|
const cleaned = resolved.pathname.replace(/^\/+/, "").replace(/^docs\//, "");
|
|
if (!cleaned || cleaned.includes("..")) {
|
|
throw new Error(DOCS_PATH_ERROR);
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
export const maxDuration = 300;
|
|
|
|
export const DOC_CHAT_PRUNE_OPTIONS = {
|
|
toolCalls: "before-last-2-messages",
|
|
reasoning: "none",
|
|
emptyMessages: "remove",
|
|
} as const;
|
|
|
|
export async function prepareDocChatMessages(messages: readonly UIMessage[]) {
|
|
const modelMessages = await convertToModelMessages(
|
|
injectQuoteContext([...messages]),
|
|
);
|
|
|
|
return pruneMessages({
|
|
messages: modelMessages,
|
|
...DOC_CHAT_PRUNE_OPTIONS,
|
|
});
|
|
}
|
|
|
|
function createRepoTools() {
|
|
let bashToolkitPromise: Promise<
|
|
Awaited<ReturnType<typeof createBashTool>>
|
|
> | null = null;
|
|
|
|
const getBashToolkit = () => {
|
|
if (!bashToolkitPromise) {
|
|
bashToolkitPromise = createBashTool({
|
|
files: SOURCE_SNAPSHOT,
|
|
destination: "/repo",
|
|
maxFiles: 5000,
|
|
maxOutputLength: 15000,
|
|
});
|
|
}
|
|
|
|
return bashToolkitPromise;
|
|
};
|
|
|
|
return {
|
|
bash: tool({
|
|
description:
|
|
"Execute bash commands in the /repo sandbox containing the assistant-ui monorepo.",
|
|
inputSchema: zodSchema(
|
|
z.object({
|
|
command: z
|
|
.string()
|
|
.describe("The bash command to execute from the /repo directory."),
|
|
}),
|
|
),
|
|
execute: async ({ command }, options) => {
|
|
const { tools } = await getBashToolkit();
|
|
return tools.bash.execute!({ command }, options);
|
|
},
|
|
}),
|
|
readFile: tool({
|
|
description: "Read the contents of a source file from the /repo sandbox.",
|
|
inputSchema: zodSchema(
|
|
z.object({
|
|
path: z
|
|
.string()
|
|
.describe("The repo-relative file path to read from /repo."),
|
|
}),
|
|
),
|
|
execute: async ({ path }, options) => {
|
|
const { tools } = await getBashToolkit();
|
|
return tools.readFile.execute!({ path }, options);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
const SYSTEM_PROMPT = `You are the assistant-ui docs assistant.
|
|
|
|
<about_assistant_ui>
|
|
assistant-ui is a React library for building AI chat interfaces. It provides:
|
|
- Composable UI primitives (Thread, Composer, Message, etc.)
|
|
- Runtime adapters for AI backends (Vercel AI SDK, LangGraph, custom stores)
|
|
- Pre-built components with full customization support
|
|
</about_assistant_ui>
|
|
|
|
<personality>
|
|
- Friendly, concise, developer-focused
|
|
- Answer the actual question - don't list documentation sections
|
|
- Use emoji sparingly (👋 for greetings, ✅ for success, etc.)
|
|
- Provide code snippets when they help clarify
|
|
- Link to relevant docs naturally within answers
|
|
</personality>
|
|
|
|
<greetings>
|
|
When users send a casual greeting (hey, hi, hello):
|
|
1. Welcome them to assistant-ui with emoji 👋
|
|
2. Briefly explain what assistant-ui helps them do (build AI chat interfaces in React)
|
|
3. Ask what they're working on or offer 2-3 common starting points
|
|
|
|
Example tone:
|
|
"Hey! 👋 Welcome to assistant-ui!
|
|
|
|
I'm here to help you build AI chat interfaces with React. Whether you're just getting started, connecting to an AI backend, or customizing components — I've got you covered.
|
|
|
|
What are you working on?"
|
|
|
|
Do NOT dump all documentation categories. Keep it conversational.
|
|
</greetings>
|
|
|
|
<tools>
|
|
You have two documentation tools:
|
|
|
|
1. **listDocs** - Browse documentation structure
|
|
- Use with no path for root categories
|
|
- Use with path (e.g., "ui", "runtimes") to see pages in that section
|
|
- Returns: list of folders and pages with URLs
|
|
|
|
2. **readDoc** - Read a specific documentation page
|
|
- Input: slug (e.g., "ui/thread") or URL (e.g., "/docs/ui/thread")
|
|
- Returns: full page content
|
|
|
|
**Recommended patterns:**
|
|
- User asks a question → listDocs to find relevant section → readDoc to get content
|
|
- User mentions a specific path → readDoc directly
|
|
</tools>
|
|
|
|
<source_code_tools>
|
|
You also have tools for exploring the actual assistant-ui source code:
|
|
|
|
3. **bash** - Execute bash commands in a sandbox containing the full monorepo
|
|
- The sandbox is at /repo with the complete source tree
|
|
- Use for: grep, find, cat, awk, head, tail, wc, ls, tree, etc.
|
|
- Example: \`grep -r "useThread" packages/ --include="*.ts" -l\`
|
|
|
|
4. **readFile** - Read a specific source file by path
|
|
- More token-efficient than \`cat\` for reading whole files
|
|
</source_code_tools>
|
|
|
|
<answering>
|
|
- Use the documentation tools to find relevant information
|
|
- **CRITICAL: ONLY use URLs that are explicitly returned by your tools**
|
|
- **NEVER guess or fabricate URLs** - if a tool didn't return a URL, don't link to it
|
|
- When linking, copy the exact URL from tool results: [Page Title](/docs/exact-path-from-tool)
|
|
- Prefer not linking over linking to a potentially non-existent page
|
|
- Admit uncertainty rather than guessing
|
|
</answering>
|
|
|
|
<formatting>
|
|
Use inline code (\`backticks\`) for:
|
|
- Components: \`Thread\`, \`Composer\`, \`Message\`
|
|
- Hooks: \`useChat\`, \`useThreadRuntime\`
|
|
- Props, parameters, types
|
|
- Packages: \`@assistant-ui/react\`
|
|
- File paths
|
|
</formatting>
|
|
`;
|
|
|
|
export async function POST(req: Request): Promise<Response> {
|
|
try {
|
|
const rateLimitResponse = await checkRateLimit(req);
|
|
if (rateLimitResponse) return rateLimitResponse;
|
|
|
|
const body = await req.json();
|
|
const { messages, tools, system: pageContext, config } = body;
|
|
|
|
const prunedMessages = await prepareDocChatMessages(messages);
|
|
|
|
const inputError = validateDocChatInput(prunedMessages);
|
|
if (inputError) return inputError;
|
|
|
|
const baseModel = getModel(config?.modelName);
|
|
const distinctId = getDistinctId(req);
|
|
const prismTracer = createPrismTracer();
|
|
|
|
const posthogModel = posthogServer
|
|
? withTracing(baseModel, posthogServer, {
|
|
posthogDistinctId: distinctId,
|
|
posthogPrivacyMode: false,
|
|
posthogProperties: {
|
|
$ai_span_name: "docs_assistant_chat",
|
|
source: "docs_assistant",
|
|
},
|
|
})
|
|
: baseModel;
|
|
|
|
const prism = prismTracer
|
|
? prismAISDK(prismTracer, posthogModel, {
|
|
name: "docs_assistant",
|
|
endUserId: distinctId,
|
|
})
|
|
: null;
|
|
|
|
const repoTools = createRepoTools();
|
|
|
|
const result = streamText({
|
|
model: prism?.model ?? posthogModel,
|
|
system: [SYSTEM_PROMPT, pageContext].filter(Boolean).join("\n\n"),
|
|
messages: prunedMessages,
|
|
maxOutputTokens: 8192,
|
|
stopWhen: stepCountIs(25),
|
|
tools: {
|
|
...frontendTools(tools),
|
|
...repoTools,
|
|
listDocs: tool({
|
|
description:
|
|
"List documentation pages. Use with no path for root categories, or specify path to browse a section.",
|
|
inputSchema: zodSchema(
|
|
z.object({
|
|
path: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Path to browse (e.g., 'ui', 'runtimes'). Empty for root.",
|
|
),
|
|
}),
|
|
),
|
|
execute: async ({ path }) => {
|
|
const pageTree = source.pageTree;
|
|
|
|
if (!path) {
|
|
// Return root categories
|
|
return [
|
|
...listChildren(
|
|
pageTree.children.filter(
|
|
(node): node is PageTree.Folder => node.type === "folder",
|
|
),
|
|
),
|
|
{
|
|
type: "folder",
|
|
name: "examples",
|
|
description:
|
|
"Examples of app types users can build with assistant-ui, showing instructions, recommended patterns, and UI structure.",
|
|
},
|
|
];
|
|
}
|
|
|
|
const segments = path.split("/").filter(Boolean);
|
|
if (segments[0] === "examples") {
|
|
const rest = segments.slice(1).join("/");
|
|
const target = rest
|
|
? findFolderByPath(examplesSource.pageTree, rest)
|
|
: examplesSource.pageTree;
|
|
if (!target) return { error: "Path not found" };
|
|
return listChildren(target.children);
|
|
}
|
|
|
|
const targetFolder = findFolderByPath(pageTree, path);
|
|
if (!targetFolder) return { error: "Path not found" };
|
|
return listChildren(targetFolder.children);
|
|
},
|
|
}),
|
|
readDoc: tool({
|
|
description: "Read full content of a documentation page",
|
|
inputSchema: zodSchema(
|
|
z.object({
|
|
slugOrUrl: z
|
|
.string()
|
|
.describe("Page slug (e.g., 'ui/thread') or URL"),
|
|
}),
|
|
),
|
|
execute: async ({ slugOrUrl }) => {
|
|
let normalized: string;
|
|
try {
|
|
normalized = normalizeDocPath(slugOrUrl, req.url);
|
|
} catch (error) {
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Invalid docs path",
|
|
};
|
|
}
|
|
|
|
const slugs = normalized.split("/").filter(Boolean);
|
|
const isExample = slugs[0] === "examples";
|
|
const docSource = isExample ? examplesSource : source;
|
|
const docSlugs = isExample ? slugs.slice(1) : slugs;
|
|
|
|
const page = docSource.getPage(docSlugs);
|
|
if (!page) return { error: `Page not found: ${slugOrUrl}` };
|
|
|
|
const content = await getLLMText(page);
|
|
return { title: page.data.title, url: page.url, content };
|
|
},
|
|
}),
|
|
},
|
|
onFinish: async () => {
|
|
await prism?.end();
|
|
},
|
|
onError: async ({ error }) => {
|
|
console.error(error);
|
|
await prism?.end({ status: "error" });
|
|
},
|
|
onAbort: async () => {
|
|
await prism?.end();
|
|
},
|
|
});
|
|
|
|
return result.toUIMessageStreamResponse({
|
|
originalMessages: messages,
|
|
// gets usage and modelId for internal telemetry
|
|
messageMetadata: ({ part }) => {
|
|
if (part.type === "finish-step") {
|
|
return { modelId: part.response.modelId };
|
|
}
|
|
if (part.type === "finish") {
|
|
return { custom: { usage: part.totalUsage } };
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error("[api/doc/chat]", e);
|
|
return new Response("Request failed", { status: 500 });
|
|
}
|
|
}
|