CLI: Support other React.js frameworks for AI chat

This commit is contained in:
Fuma Nama
2026-04-01 00:20:41 +08:00
parent 3cc9fbc3a4
commit a77a4addc2
6 changed files with 83 additions and 54 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Support other React.js frameworks for AI chat
+8
View File
@@ -18,6 +18,14 @@ export const registry: Registry = {
if (filePath.startsWith('lib/source/')) return false;
},
onResolve(ref) {
if (ref.type === 'unknown-specifier' && ref.specifier === 'hast') {
return {
type: 'dependency',
dep: '@types/hast',
specifier: 'hast',
};
}
if (ref.type === 'file') {
const file = path.relative(baseDir, ref.file);
@@ -386,7 +386,7 @@ const markdownUrl = `${page.url}.mdx`
You can install the AI chat dialog using Fumadocs CLI.
<Tabs items={['OpenRouter', 'Inkeep AI']}>
<Tabs items={['AI SDK', 'Inkeep AI']}>
<Tab>
```npm
@@ -395,6 +395,12 @@ npx @fumadocs/cli add ai/openrouter
It's automatically configured for [OpenRouter](https://openrouter.ai) using Vercel AI SDK, with a `/search` tool for AI.
You can use other models by updating the `/api/chat` route.
> Fumadocs doesn't provide the AI model, it's up to you.
>
> Your AI model can use the `llms-full.txt` file generated above, or more diversified sources of information when combined with 3rd party solutions.
</Tab>
<Tab>
@@ -413,43 +419,37 @@ INKEEP_API_KEY="..."
</Tab>
</Tabs>
Add the component & trigger to root layout (or anywhere you prefer):
Add the component & trigger to docs layout:
```tsx
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { AISearch, AISearchPanel, AISearchTrigger } from '@/components/ai/search';
import { MessageCircleIcon } from 'lucide-react';
// or import your own button styles
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* [!code ++:17] */}
<AISearch>
<AISearchPanel />
<AISearchTrigger
position="float"
className={cn(
buttonVariants({
variant: 'secondary',
className: 'text-fd-muted-foreground rounded-2xl',
}),
)}
>
<MessageCircleIcon className="size-4.5" />
Ask AI
</AISearchTrigger>
</AISearch>
<DocsLayout>
{/* [!code ++:17] */}
<AISearch>
<AISearchPanel />
<AISearchTrigger
position="float"
className={cn(
buttonVariants({
variant: 'secondary',
className: 'text-fd-muted-foreground rounded-2xl',
}),
)}
>
<MessageCircleIcon className="size-4.5" />
Ask AI
</AISearchTrigger>
</AISearch>
{children}
</body>
</html>
{children}
</DocsLayout>
);
}
```
To use your own AI models, update the configurations in `useChat` and `/api/chat` route.
Note that Fumadocs doesn't provide the AI model, it's up to you.
Your AI model can use the `llms-full.txt` file generated above, or more diversified sources of information when combined with 3rd party solutions.
@@ -2,10 +2,9 @@ import path from 'node:path';
import MagicString from 'magic-string';
import { Visitor } from 'oxc-parser';
import type {
ArrowFunctionExpression,
Argument,
CallExpression,
Expression,
Function as AstFunction,
ImportDeclaration,
ObjectExpression,
ParamPattern,
@@ -165,7 +164,7 @@ interface HandlerInfo {
bodyText: string;
}
function parseHandlerFromAst(s: MagicString, expr: Expression): HandlerInfo {
function parseHandlerFromAst(s: MagicString, expr: Argument): HandlerInfo {
const isFn = expr.type === 'FunctionExpression';
const isArrow = expr.type === 'ArrowFunctionExpression';
if (!isFn && !isArrow) {
@@ -173,7 +172,7 @@ function parseHandlerFromAst(s: MagicString, expr: Expression): HandlerInfo {
'route-handler.build: second argument to $routeHandler must be a function or async arrow function',
);
}
const fn = expr as ArrowFunctionExpression | (AstFunction & { type: 'FunctionExpression' });
const fn = expr;
if (!fn.async) {
throw new Error('route-handler.build: route handler must be async');
}
@@ -440,8 +439,8 @@ export function buildRouteHandlerFile(
throw new Error('route-handler.build: $routeHandler must be called with (info, handler)');
}
const arg0 = call.arguments[0] as Expression;
const arg1 = call.arguments[1] as Expression;
const arg0 = call.arguments[0];
const arg1 = call.arguments[1];
if (arg0.type !== 'ObjectExpression') {
throw new Error(
'route-handler.build: first argument to $routeHandler must be an object literal',
+2 -2
View File
@@ -252,9 +252,9 @@ async function main(): Promise<void> {
}
if (options.aiChat) {
const { nextUseAi } = await import('./plugins/next-use-ai');
const { ai } = await import('./plugins/ai');
plugins.push(nextUseAi(options.aiChat));
plugins.push(ai(options.aiChat));
}
await create({
@@ -7,23 +7,21 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import { SyntaxKind } from 'ts-morph';
export function nextUseAi(provider: 'openrouter' | 'inkeep'): TemplatePlugin {
export function ai(provider: 'openrouter' | 'inkeep'): TemplatePlugin {
return {
async afterWrite() {
if (this.template.value === '+next+fuma-docs-mdx') {
const config = await getDefaultConfig();
await install(
`ai/${provider}`,
new ComponentInstaller(new HttpRegistryClient('https://fumadocs.dev/registry', config), {
cwd: this.dest,
}),
);
await addAIChat(this);
await fs.writeFile(
path.join(this.dest, '.env.local'),
provider === 'openrouter' ? 'OPENROUTER_API_KEY=' : 'INKEEP_API_KEY=',
);
}
const config = await getDefaultConfig();
await install(
`ai/${provider}`,
new ComponentInstaller(new HttpRegistryClient('https://fumadocs.dev/registry', config), {
cwd: this.dest,
}),
);
await addAIChat(this);
await fs.writeFile(
path.join(this.dest, '.env.local'),
provider === 'openrouter' ? 'OPENROUTER_API_KEY=' : 'INKEEP_API_KEY=',
);
},
};
}
@@ -47,8 +45,27 @@ async function install(target: string, installer: ComponentInstaller) {
await installer.onEnd();
}
async function addAIChat({ appDir }: TemplatePluginContext) {
const file = await createSourceFile(path.join(appDir, 'app/docs/layout.tsx'));
async function addAIChat({ template, appDir }: TemplatePluginContext) {
let filePath: string;
switch (template.value) {
case '+next+fuma-docs-mdx':
case '+next+fuma-docs-mdx+static':
filePath = path.join(appDir, 'app/docs/layout.tsx');
break;
case 'waku':
filePath = path.join(appDir, 'pages/docs/_layout.tsx');
break;
case 'react-router':
case 'react-router-spa':
filePath = path.join(appDir, 'routes/docs.tsx');
break;
case 'tanstack-start':
case 'tanstack-start-spa':
filePath = path.join(appDir, 'routes/docs/$.tsx');
break;
}
const file = await createSourceFile(filePath);
const elements = file.getDescendantsOfKind(SyntaxKind.JsxElement);
const code = `<AISearch>
<AISearchPanel />