├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── actions.ts ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── chat │ │ └── route.ts ├── chat │ └── [id] │ │ └── page.tsx ├── globals.css ├── layout.tsx ├── opengraph-image.png ├── page.tsx ├── share │ └── [id] │ │ ├── opengraph-image.tsx │ │ └── page.tsx ├── sign-in │ └── page.tsx └── twitter-image.png ├── assets └── fonts │ ├── Inter-Bold.woff │ └── Inter-Regular.woff ├── auth.ts ├── components ├── button-scroll-to-bottom.tsx ├── chat-list.tsx ├── chat-message-actions.tsx ├── chat-message.tsx ├── chat-panel.tsx ├── chat-scroll-anchor.tsx ├── chat.tsx ├── clear-history.tsx ├── empty-screen.tsx ├── external-link.tsx ├── footer.tsx ├── header.tsx ├── login-button.tsx ├── markdown.tsx ├── prompt-form.tsx ├── providers.tsx ├── sidebar-actions.tsx ├── sidebar-footer.tsx ├── sidebar-item.tsx ├── sidebar-list.tsx ├── sidebar.tsx ├── tailwind-indicator.tsx ├── theme-toggle.tsx ├── toaster.tsx ├── ui │ ├── alert-dialog.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── codeblock.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── icons.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── switch.tsx │ ├── textarea.tsx │ └── tooltip.tsx └── user-menu.tsx ├── lib ├── analytics.ts ├── fonts.ts ├── hooks │ ├── use-at-bottom.tsx │ ├── use-copy-to-clipboard.tsx │ ├── use-enter-submit.tsx │ └── use-local-storage.ts ├── types.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── public ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview 2 | # Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys 3 | OPENAI_API_KEY=XXXXXXXX 4 | 5 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 6 | AUTH_SECRET=XXXXXXXX 7 | # Create a GitHub OAuth app here: https://github.com/settings/applications/new 8 | # Authorization callback URL: https://authjs.dev/reference/core/providers_github#callback-url 9 | AUTH_GITHUB_ID=XXXXXXXX 10 | AUTH_GITHUB_SECRET=XXXXXXXX 11 | # Support OAuth login on preview deployments, see: https://authjs.dev/guides/basics/deployment#securing-a-preview-deployment 12 | # Set the following only when deployed. In this example, we can reuse the same OAuth app, but if you are storing users, we recommend using a different OAuth app for development/production so that you don't mix your test and production user base. 13 | # AUTH_REDIRECT_PROXY_URL=https://YOURAPP.vercel.app/api/auth 14 | 15 | # Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and 16 | KV_URL=XXXXXXXX 17 | KV_REST_API_URL=XXXXXXXX 18 | KV_REST_API_TOKEN=XXXXXXXX 19 | KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX 20 | 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "tailwindcss/no-custom-classname": "off" 12 | }, 13 | "settings": { 14 | "tailwindcss": { 15 | "callees": ["cn", "cva"], 16 | "config": "tailwind.config.js" 17 | } 18 | }, 19 | "overrides": [ 20 | { 21 | "files": ["*.ts", "*.tsx"], 22 | "parser": "@typescript-eslint/parser" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env 37 | .vercel 38 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Next.js 13 and app template Router-ready AI chatbot. 3 |

Next.js AI Chatbot

4 |
5 | 6 |

7 | An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Vercel KV. 8 |

9 | 10 |

11 | Features · 12 | Model Providers · 13 | Deploy Your Own · 14 | Running locally · 15 | Authors 16 |

17 |
18 | 19 | ## Features 20 | 21 | - [Next.js](https://nextjs.org) App Router 22 | - React Server Components (RSCs), Suspense, and Server Actions 23 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI 24 | - Support for OpenAI (default), Anthropic, Hugging Face, or custom AI chat models and/or LangChain 25 | - Edge runtime-ready 26 | - [shadcn/ui](https://ui.shadcn.com) 27 | - Styling with [Tailwind CSS](https://tailwindcss.com) 28 | - [Radix UI](https://radix-ui.com) for headless component primitives 29 | - Icons from [Phosphor Icons](https://phosphoricons.com) 30 | - Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) 31 | - [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication 32 | 33 | ## Model Providers 34 | 35 | This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Anthropic](https://anthropic.com), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. 36 | 37 | ## Deploy Your Own 38 | 39 | You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: 40 | 41 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_GITHUB_ID%2CAUTH_GITHUB_SECRET%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"}]) 42 | 43 | ## Creating a KV Database Instance 44 | 45 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it. 46 | 47 | Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. 48 | 49 | 50 | ## Running locally 51 | 52 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary. 53 | 54 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts. 55 | 56 | 1. Install Vercel CLI: `npm i -g vercel` 57 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 58 | 3. Download your environment variables: `vercel env pull` 59 | 60 | ```bash 61 | pnpm install 62 | pnpm dev 63 | ``` 64 | 65 | Your app template should now be running on [localhost:3000](http://localhost:3000/). 66 | 67 | ## Authors 68 | 69 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: 70 | 71 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) 72 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) 73 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Contractor](https://shadcn.com) 74 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { revalidatePath } from 'next/cache' 4 | import { redirect } from 'next/navigation' 5 | import { kv } from '@vercel/kv' 6 | 7 | import { auth } from '@/auth' 8 | import { type Chat } from '@/lib/types' 9 | 10 | export async function getChats(userId?: string | null) { 11 | if (!userId) { 12 | return [] 13 | } 14 | 15 | try { 16 | const pipeline = kv.pipeline() 17 | const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, { 18 | rev: true 19 | }) 20 | 21 | for (const chat of chats) { 22 | pipeline.hgetall(chat) 23 | } 24 | 25 | const results = await pipeline.exec() 26 | 27 | return results as Chat[] 28 | } catch (error) { 29 | return [] 30 | } 31 | } 32 | 33 | export async function getChat(id: string, userId: string) { 34 | const chat = await kv.hgetall(`chat:${id}`) 35 | 36 | if (!chat || (userId && chat.userId !== userId)) { 37 | return null 38 | } 39 | 40 | return chat 41 | } 42 | 43 | export async function removeChat({ id, path }: { id: string; path: string }) { 44 | const session = await auth() 45 | 46 | if (!session) { 47 | return { 48 | error: 'Unauthorized' 49 | } 50 | } 51 | 52 | const uid = await kv.hget(`chat:${id}`, 'userId') 53 | 54 | if (uid !== session?.user?.id) { 55 | return { 56 | error: 'Unauthorized' 57 | } 58 | } 59 | 60 | await kv.del(`chat:${id}`) 61 | await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`) 62 | 63 | revalidatePath('/') 64 | return revalidatePath(path) 65 | } 66 | 67 | export async function clearChats() { 68 | const session = await auth() 69 | 70 | if (!session?.user?.id) { 71 | return { 72 | error: 'Unauthorized' 73 | } 74 | } 75 | 76 | const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1) 77 | if (!chats.length) { 78 | return redirect('/') 79 | } 80 | const pipeline = kv.pipeline() 81 | 82 | for (const chat of chats) { 83 | pipeline.del(chat) 84 | pipeline.zrem(`user:chat:${session.user.id}`, chat) 85 | } 86 | 87 | await pipeline.exec() 88 | 89 | revalidatePath('/') 90 | return redirect('/') 91 | } 92 | 93 | export async function getSharedChat(id: string) { 94 | const chat = await kv.hgetall(`chat:${id}`) 95 | 96 | if (!chat || !chat.sharePath) { 97 | return null 98 | } 99 | 100 | return chat 101 | } 102 | 103 | export async function shareChat(chat: Chat) { 104 | const session = await auth() 105 | 106 | if (!session?.user?.id || session.user.id !== chat.userId) { 107 | return { 108 | error: 'Unauthorized' 109 | } 110 | } 111 | 112 | const payload = { 113 | ...chat, 114 | sharePath: `/share/${chat.id}` 115 | } 116 | 117 | await kv.hmset(`chat:${chat.id}`, payload) 118 | 119 | return payload 120 | } 121 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from '@/auth' 2 | export const runtime = 'edge' 3 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { kv } from '@vercel/kv' 2 | import { OpenAIStream, StreamingTextResponse } from 'ai' 3 | import { Configuration, OpenAIApi } from 'openai-edge' 4 | 5 | import { auth } from '@/auth' 6 | import { nanoid } from '@/lib/utils' 7 | 8 | export const runtime = 'edge' 9 | 10 | const configuration = new Configuration({ 11 | apiKey: process.env.OPENAI_API_KEY 12 | }) 13 | 14 | const openai = new OpenAIApi(configuration) 15 | 16 | export async function POST(req: Request) { 17 | const json = await req.json() 18 | const { messages, previewToken } = json 19 | const userId = (await auth())?.user.id 20 | 21 | if (!userId) { 22 | return new Response('Unauthorized', { 23 | status: 401 24 | }) 25 | } 26 | 27 | if (previewToken) { 28 | configuration.apiKey = previewToken 29 | } 30 | 31 | const res = await openai.createChatCompletion({ 32 | model: 'gpt-3.5-turbo', 33 | messages, 34 | temperature: 0.7, 35 | stream: true 36 | }) 37 | 38 | const stream = OpenAIStream(res, { 39 | async onCompletion(completion) { 40 | const title = json.messages[0].content.substring(0, 100) 41 | const id = json.id ?? nanoid() 42 | const createdAt = Date.now() 43 | const path = `/chat/${id}` 44 | const payload = { 45 | id, 46 | title, 47 | userId, 48 | createdAt, 49 | path, 50 | messages: [ 51 | ...messages, 52 | { 53 | content: completion, 54 | role: 'assistant' 55 | } 56 | ] 57 | } 58 | await kv.hmset(`chat:${id}`, payload) 59 | await kv.zadd(`user:chat:${userId}`, { 60 | score: createdAt, 61 | member: `chat:${id}` 62 | }) 63 | } 64 | }) 65 | 66 | return new StreamingTextResponse(stream) 67 | } 68 | -------------------------------------------------------------------------------- /app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next' 2 | import { notFound, redirect } from 'next/navigation' 3 | 4 | import { auth } from '@/auth' 5 | import { getChat } from '@/app/actions' 6 | import { Chat } from '@/components/chat' 7 | 8 | export const runtime = 'edge' 9 | export const preferredRegion = 'home' 10 | 11 | export interface ChatPageProps { 12 | params: { 13 | id: string 14 | } 15 | } 16 | 17 | export async function generateMetadata({ 18 | params 19 | }: ChatPageProps): Promise { 20 | const session = await auth() 21 | 22 | if (!session?.user) { 23 | return {} 24 | } 25 | 26 | const chat = await getChat(params.id, session.user.id) 27 | return { 28 | title: chat?.title.toString().slice(0, 50) ?? 'Chat' 29 | } 30 | } 31 | 32 | export default async function ChatPage({ params }: ChatPageProps) { 33 | const session = await auth() 34 | 35 | if (!session?.user) { 36 | redirect(`/sign-in?next=/chat/${params.id}`) 37 | } 38 | 39 | const chat = await getChat(params.id, session.user.id) 40 | 41 | if (!chat) { 42 | notFound() 43 | } 44 | 45 | if (chat?.userId !== session?.user?.id) { 46 | notFound() 47 | } 48 | 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --muted: 240 4.8% 95.9%; 11 | --muted-foreground: 240 3.8% 46.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 240 10% 3.9%; 18 | 19 | --border: 240 5.9% 90%; 20 | --input: 240 5.9% 90%; 21 | 22 | --primary: 240 5.9% 10%; 23 | --primary-foreground: 0 0% 98%; 24 | 25 | --secondary: 240 4.8% 95.9%; 26 | --secondary-foreground: 240 5.9% 10%; 27 | 28 | --accent: 240 4.8% 95.9%; 29 | --accent-foreground: ; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | 34 | --ring: 240 5% 64.9%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 240 10% 3.9%; 41 | --foreground: 0 0% 98%; 42 | 43 | --muted: 240 3.7% 15.9%; 44 | --muted-foreground: 240 5% 64.9%; 45 | 46 | --popover: 240 10% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | 49 | --card: 240 10% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | 52 | --border: 240 3.7% 15.9%; 53 | --input: 240 3.7% 15.9%; 54 | 55 | --primary: 0 0% 98%; 56 | --primary-foreground: 240 5.9% 10%; 57 | 58 | --secondary: 240 3.7% 15.9%; 59 | --secondary-foreground: 0 0% 98%; 60 | 61 | --accent: 240 3.7% 15.9%; 62 | --accent-foreground: ; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 240 3.7% 15.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | 3 | import { Toaster } from 'react-hot-toast' 4 | 5 | import '@/app/globals.css' 6 | import { fontMono, fontSans } from '@/lib/fonts' 7 | import { cn } from '@/lib/utils' 8 | import { TailwindIndicator } from '@/components/tailwind-indicator' 9 | import { Providers } from '@/components/providers' 10 | import { Header } from '@/components/header' 11 | 12 | export const metadata: Metadata = { 13 | title: { 14 | default: 'Next.js AI Chatbot', 15 | template: `%s - Next.js AI Chatbot` 16 | }, 17 | description: 'An AI-powered chatbot template built with Next.js and Vercel.', 18 | themeColor: [ 19 | { media: '(prefers-color-scheme: light)', color: 'white' }, 20 | { media: '(prefers-color-scheme: dark)', color: 'black' } 21 | ], 22 | icons: { 23 | icon: '/favicon.ico', 24 | shortcut: '/favicon-16x16.png', 25 | apple: '/apple-touch-icon.png' 26 | } 27 | } 28 | 29 | interface RootLayoutProps { 30 | children: React.ReactNode 31 | } 32 | 33 | export default function RootLayout({ children }: RootLayoutProps) { 34 | return ( 35 | 36 | 37 | 44 | 45 | 46 |
47 | {/* @ts-ignore */} 48 |
49 |
{children}
50 |
51 | 52 |
53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadcn/ai-chatbot/0a201f63c3c2a1cf78ccb69f96dbe25ae3f9d1ba/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from '@/lib/utils' 2 | import { Chat } from '@/components/chat' 3 | 4 | export const runtime = 'edge' 5 | 6 | export default function IndexPage() { 7 | const id = nanoid() 8 | 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /app/share/[id]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/server' 2 | 3 | import { getSharedChat } from '@/app/actions' 4 | 5 | export const runtime = 'edge' 6 | 7 | export const alt = 'AI Chatbot' 8 | 9 | export const size = { 10 | width: 1200, 11 | height: 630 12 | } 13 | 14 | export const contentType = 'image/png' 15 | 16 | const interRegular = fetch( 17 | new URL('../../../assets/fonts/Inter-Regular.woff', import.meta.url) 18 | ).then(res => res.arrayBuffer()) 19 | 20 | const interBold = fetch( 21 | new URL('../../../assets/fonts/Inter-Bold.woff', import.meta.url) 22 | ).then(res => res.arrayBuffer()) 23 | 24 | interface ImageProps { 25 | params: { 26 | id: string 27 | } 28 | } 29 | 30 | export default async function Image({ params }: ImageProps) { 31 | const chat = await getSharedChat(params.id) 32 | 33 | if (!chat || !chat?.sharePath) { 34 | return null 35 | } 36 | 37 | const textAlign = chat?.title?.length > 40 ? 'items-start' : 'items-center' 38 | 39 | return new ImageResponse( 40 | ( 41 |
42 |
43 |
44 |
45 | 52 | 53 | 54 |
55 |
56 | {chat.title.length > 120 57 | ? `${chat.title.slice(0, 120)}...` 58 | : chat.title} 59 |
60 |
61 |
62 |
63 | 71 | 72 | 73 |
74 |
75 | ... 76 |
77 |
78 |
79 |
80 |
81 | 88 | 89 | 90 |
91 | Built with{' '} 92 |
Vercel AI SDK
& 93 |
KV
94 |
95 |
96 |
chat.vercel.ai
97 |
98 |
99 | ), 100 | { 101 | ...size, 102 | fonts: [ 103 | { 104 | name: 'Inter', 105 | data: await interRegular, 106 | style: 'normal', 107 | weight: 400 108 | }, 109 | { 110 | name: 'Inter', 111 | data: await interBold, 112 | style: 'normal', 113 | weight: 700 114 | } 115 | ] 116 | } 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /app/share/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | 4 | import { formatDate } from '@/lib/utils' 5 | import { getSharedChat } from '@/app/actions' 6 | import { ChatList } from '@/components/chat-list' 7 | import { FooterText } from '@/components/footer' 8 | 9 | export const runtime = 'edge' 10 | export const preferredRegion = 'home' 11 | 12 | interface SharePageProps { 13 | params: { 14 | id: string 15 | } 16 | } 17 | 18 | export async function generateMetadata({ 19 | params 20 | }: SharePageProps): Promise { 21 | const chat = await getSharedChat(params.id) 22 | 23 | return { 24 | title: chat?.title.slice(0, 50) ?? 'Chat' 25 | } 26 | } 27 | 28 | export default async function SharePage({ params }: SharePageProps) { 29 | const chat = await getSharedChat(params.id) 30 | 31 | if (!chat || !chat?.sharePath) { 32 | notFound() 33 | } 34 | 35 | return ( 36 | <> 37 |
38 |
39 |
40 |
41 |

{chat.title}

42 |
43 | {formatDate(chat.createdAt)} · {chat.messages.length} messages 44 |
45 |
46 |
47 |
48 | 49 |
50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import { LoginButton } from '@/components/login-button' 3 | import { redirect } from 'next/navigation' 4 | 5 | export default async function SignInPage() { 6 | const session = await auth() 7 | // redirect to home if user is already logged in 8 | if (session?.user) { 9 | redirect('/') 10 | } 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadcn/ai-chatbot/0a201f63c3c2a1cf78ccb69f96dbe25ae3f9d1ba/app/twitter-image.png -------------------------------------------------------------------------------- /assets/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadcn/ai-chatbot/0a201f63c3c2a1cf78ccb69f96dbe25ae3f9d1ba/assets/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadcn/ai-chatbot/0a201f63c3c2a1cf78ccb69f96dbe25ae3f9d1ba/assets/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { type DefaultSession } from 'next-auth' 2 | import GitHub from 'next-auth/providers/github' 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | user: { 7 | /** The user's id. */ 8 | id: string 9 | } & DefaultSession['user'] 10 | } 11 | } 12 | 13 | export const { 14 | handlers: { GET, POST }, 15 | auth, 16 | CSRF_experimental // will be removed in future 17 | } = NextAuth({ 18 | providers: [GitHub], 19 | callbacks: { 20 | jwt({ token, profile }) { 21 | if (profile) { 22 | token.id = profile.id 23 | token.image = profile.avatar_url || profile.picture 24 | } 25 | return token 26 | }, 27 | authorized({ auth }) { 28 | return !!auth?.user // this ensures there is a logged in user for -every- request 29 | } 30 | }, 31 | pages: { 32 | signIn: '/sign-in' // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /components/button-scroll-to-bottom.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { useAtBottom } from '@/lib/hooks/use-at-bottom' 7 | import { Button, type ButtonProps } from '@/components/ui/button' 8 | import { IconArrowDown } from '@/components/ui/icons' 9 | 10 | export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { 11 | const isAtBottom = useAtBottom() 12 | 13 | return ( 14 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/chat-list.tsx: -------------------------------------------------------------------------------- 1 | import { type Message } from 'ai' 2 | 3 | import { Separator } from '@/components/ui/separator' 4 | import { ChatMessage } from '@/components/chat-message' 5 | 6 | export interface ChatList { 7 | messages: Message[] 8 | } 9 | 10 | export function ChatList({ messages }: ChatList) { 11 | if (!messages.length) { 12 | return null 13 | } 14 | 15 | return ( 16 |
17 | {messages.map((message, index) => ( 18 |
19 | 20 | {index < messages.length - 1 && ( 21 | 22 | )} 23 |
24 | ))} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/chat-message-actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type Message } from 'ai' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { IconCheck, IconCopy } from '@/components/ui/icons' 7 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' 8 | import { cn } from '@/lib/utils' 9 | 10 | interface ChatMessageActionsProps extends React.ComponentProps<'div'> { 11 | message: Message 12 | } 13 | 14 | export function ChatMessageActions({ 15 | message, 16 | className, 17 | ...props 18 | }: ChatMessageActionsProps) { 19 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) 20 | 21 | const onCopy = () => { 22 | if (isCopied) return 23 | copyToClipboard(message.content) 24 | } 25 | 26 | return ( 27 |
34 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/chat-message.tsx: -------------------------------------------------------------------------------- 1 | // Inspired by Chatbot-UI and modified to fit the needs of this project 2 | // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx 3 | 4 | import { Message } from 'ai' 5 | import remarkGfm from 'remark-gfm' 6 | import remarkMath from 'remark-math' 7 | 8 | import { cn } from '@/lib/utils' 9 | import { CodeBlock } from '@/components/ui/codeblock' 10 | import { MemoizedReactMarkdown } from '@/components/markdown' 11 | import { IconOpenAI, IconUser } from '@/components/ui/icons' 12 | import { ChatMessageActions } from '@/components/chat-message-actions' 13 | 14 | export interface ChatMessageProps { 15 | message: Message 16 | } 17 | 18 | export function ChatMessage({ message, ...props }: ChatMessageProps) { 19 | return ( 20 |
24 |
32 | {message.role === 'user' ? : } 33 |
34 |
35 | {children}

41 | }, 42 | code({ node, inline, className, children, ...props }) { 43 | if (children.length) { 44 | if (children[0] == '▍') { 45 | return ( 46 | 47 | ) 48 | } 49 | 50 | children[0] = (children[0] as string).replace('`▍`', '▍') 51 | } 52 | 53 | const match = /language-(\w+)/.exec(className || '') 54 | 55 | if (inline) { 56 | return ( 57 | 58 | {children} 59 | 60 | ) 61 | } 62 | 63 | return ( 64 | 70 | ) 71 | } 72 | }} 73 | > 74 | {message.content} 75 |
76 | 77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /components/chat-panel.tsx: -------------------------------------------------------------------------------- 1 | import { type UseChatHelpers } from 'ai/react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { PromptForm } from '@/components/prompt-form' 5 | import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' 6 | import { IconRefresh, IconStop } from '@/components/ui/icons' 7 | import { FooterText } from '@/components/footer' 8 | 9 | export interface ChatPanelProps 10 | extends Pick< 11 | UseChatHelpers, 12 | | 'append' 13 | | 'isLoading' 14 | | 'reload' 15 | | 'messages' 16 | | 'stop' 17 | | 'input' 18 | | 'setInput' 19 | > { 20 | id?: string 21 | } 22 | 23 | export function ChatPanel({ 24 | id, 25 | isLoading, 26 | stop, 27 | append, 28 | reload, 29 | input, 30 | setInput, 31 | messages 32 | }: ChatPanelProps) { 33 | return ( 34 |
35 | 36 |
37 |
38 | {isLoading ? ( 39 | 47 | ) : ( 48 | messages?.length > 0 && ( 49 | 57 | ) 58 | )} 59 |
60 |
61 | { 63 | await append({ 64 | id, 65 | content: value, 66 | role: 'user' 67 | }) 68 | }} 69 | input={input} 70 | setInput={setInput} 71 | isLoading={isLoading} 72 | /> 73 | 74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /components/chat-scroll-anchor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useInView } from 'react-intersection-observer' 5 | 6 | import { useAtBottom } from '@/lib/hooks/use-at-bottom' 7 | 8 | interface ChatScrollAnchorProps { 9 | trackVisibility?: boolean 10 | } 11 | 12 | export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { 13 | const isAtBottom = useAtBottom() 14 | const { ref, entry, inView } = useInView({ 15 | trackVisibility, 16 | delay: 100, 17 | rootMargin: '0px 0px -150px 0px' 18 | }) 19 | 20 | React.useEffect(() => { 21 | if (isAtBottom && trackVisibility && !inView) { 22 | entry?.target.scrollIntoView({ 23 | block: 'start' 24 | }) 25 | } 26 | }, [inView, entry, isAtBottom, trackVisibility]) 27 | 28 | return
29 | } 30 | -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useChat, type Message } from 'ai/react' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { ChatList } from '@/components/chat-list' 7 | import { ChatPanel } from '@/components/chat-panel' 8 | import { EmptyScreen } from '@/components/empty-screen' 9 | import { ChatScrollAnchor } from '@/components/chat-scroll-anchor' 10 | import { useLocalStorage } from '@/lib/hooks/use-local-storage' 11 | import { 12 | Dialog, 13 | DialogContent, 14 | DialogDescription, 15 | DialogFooter, 16 | DialogHeader, 17 | DialogTitle 18 | } from '@/components/ui/dialog' 19 | import { useState } from 'react' 20 | import { Button } from './ui/button' 21 | import { Input } from './ui/input' 22 | import { toast } from 'react-hot-toast' 23 | 24 | const IS_PREVIEW = process.env.VERCEL_ENV === 'preview' 25 | export interface ChatProps extends React.ComponentProps<'div'> { 26 | initialMessages?: Message[] 27 | id?: string 28 | } 29 | 30 | export function Chat({ id, initialMessages, className }: ChatProps) { 31 | const [previewToken, setPreviewToken] = useLocalStorage( 32 | 'ai-token', 33 | null 34 | ) 35 | const [previewTokenDialog, setPreviewTokenDialog] = useState(IS_PREVIEW) 36 | const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '') 37 | const { messages, append, reload, stop, isLoading, input, setInput } = 38 | useChat({ 39 | initialMessages, 40 | id, 41 | body: { 42 | id, 43 | previewToken 44 | }, 45 | onResponse(response) { 46 | if (response.status === 401) { 47 | toast.error(response.statusText) 48 | } 49 | } 50 | }) 51 | return ( 52 | <> 53 |
54 | {messages.length ? ( 55 | <> 56 | 57 | 58 | 59 | ) : ( 60 | 61 | )} 62 |
63 | 73 | 74 | 75 | 76 | 77 | Enter your OpenAI Key 78 | 79 | If you have not obtained your OpenAI API key, you can do so by{' '} 80 | 84 | signing up 85 | {' '} 86 | on the OpenAI website. This is only necessary for preview 87 | environments so that the open source community can test the app. 88 | The token will be saved to your browser's local storage under 89 | the name ai-token. 90 | 91 | 92 | setPreviewTokenInput(e.target.value)} 96 | /> 97 | 98 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /components/clear-history.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useRouter } from 'next/navigation' 5 | import { toast } from 'react-hot-toast' 6 | 7 | import { ServerActionResult } from '@/lib/types' 8 | import { Button } from '@/components/ui/button' 9 | import { 10 | AlertDialog, 11 | AlertDialogAction, 12 | AlertDialogCancel, 13 | AlertDialogContent, 14 | AlertDialogDescription, 15 | AlertDialogFooter, 16 | AlertDialogHeader, 17 | AlertDialogTitle, 18 | AlertDialogTrigger 19 | } from '@/components/ui/alert-dialog' 20 | import { IconSpinner } from '@/components/ui/icons' 21 | 22 | interface ClearHistoryProps { 23 | clearChats: () => ServerActionResult 24 | } 25 | 26 | export function ClearHistory({ clearChats }: ClearHistoryProps) { 27 | const [open, setOpen] = React.useState(false) 28 | const [isPending, startTransition] = React.useTransition() 29 | const router = useRouter() 30 | 31 | return ( 32 | 33 | 34 | 38 | 39 | 40 | 41 | Are you absolutely sure? 42 | 43 | This will permanently delete your chat history and remove your data 44 | from our servers. 45 | 46 | 47 | 48 | Cancel 49 | { 52 | event.preventDefault() 53 | startTransition(async () => { 54 | const result = await clearChats() 55 | 56 | if (result && 'error' in result) { 57 | toast.error(result.error) 58 | return 59 | } 60 | 61 | setOpen(false) 62 | router.push('/') 63 | }) 64 | }} 65 | > 66 | {isPending && } 67 | Delete 68 | 69 | 70 | 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /components/empty-screen.tsx: -------------------------------------------------------------------------------- 1 | import { UseChatHelpers } from 'ai/react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { ExternalLink } from '@/components/external-link' 5 | import { IconArrowRight } from '@/components/ui/icons' 6 | 7 | const exampleMessages = [ 8 | { 9 | heading: 'Explain technical concepts', 10 | message: `What is a "serverless function"?` 11 | }, 12 | { 13 | heading: 'Summarize an article', 14 | message: 'Summarize the following article for a 2nd grader: \n' 15 | }, 16 | { 17 | heading: 'Draft an email', 18 | message: `Draft an email to my boss about the following: \n` 19 | } 20 | ] 21 | 22 | export function EmptyScreen({ setInput }: Pick) { 23 | return ( 24 |
25 |
26 |

27 | Welcome to Next.js AI Chatbot! 28 |

29 |

30 | This is an open source AI chatbot app template built with{' '} 31 | Next.js and{' '} 32 | 33 | Vercel KV 34 | 35 | . 36 |

37 |

38 | You can start a conversation here or try the following examples: 39 |

40 |
41 | {exampleMessages.map((message, index) => ( 42 | 51 | ))} 52 |
53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/external-link.tsx: -------------------------------------------------------------------------------- 1 | export function ExternalLink({ 2 | href, 3 | children 4 | }: { 5 | href: string 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 14 | {children} 15 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { ExternalLink } from '@/components/external-link' 5 | 6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { 7 | return ( 8 |

15 | Open source AI chatbot built with{' '} 16 | Next.js and{' '} 17 | 18 | Vercel KV 19 | 20 | . 21 |

22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Link from 'next/link' 3 | 4 | import { cn } from '@/lib/utils' 5 | import { auth } from '@/auth' 6 | import { clearChats } from '@/app/actions' 7 | import { Button, buttonVariants } from '@/components/ui/button' 8 | import { Sidebar } from '@/components/sidebar' 9 | import { SidebarList } from '@/components/sidebar-list' 10 | import { 11 | IconGitHub, 12 | IconNextChat, 13 | IconSeparator, 14 | IconVercel 15 | } from '@/components/ui/icons' 16 | import { SidebarFooter } from '@/components/sidebar-footer' 17 | import { ThemeToggle } from '@/components/theme-toggle' 18 | import { ClearHistory } from '@/components/clear-history' 19 | import { UserMenu } from '@/components/user-menu' 20 | import { LoginButton } from '@/components/login-button' 21 | 22 | export async function Header() { 23 | const session = await auth() 24 | return ( 25 |
26 |
27 | {session?.user ? ( 28 | 29 | }> 30 | {/* @ts-ignore */} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) : ( 39 | 40 | 41 | 42 | 43 | )} 44 |
45 | 46 | {session?.user ? ( 47 | 48 | ) : ( 49 | 52 | )} 53 |
54 |
55 | 75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /components/login-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { signIn } from 'next-auth/react' 5 | 6 | import { cn } from '@/lib/utils' 7 | import { Button, type ButtonProps } from '@/components/ui/button' 8 | import { IconGitHub, IconSpinner } from '@/components/ui/icons' 9 | 10 | interface LoginButtonProps extends ButtonProps { 11 | showGithubIcon?: boolean 12 | text?: string 13 | } 14 | 15 | export function LoginButton({ 16 | text = 'Login with GitHub', 17 | showGithubIcon = true, 18 | className, 19 | ...props 20 | }: LoginButtonProps) { 21 | const [isLoading, setIsLoading] = React.useState(false) 22 | return ( 23 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from 'react' 2 | import ReactMarkdown, { Options } from 'react-markdown' 3 | 4 | export const MemoizedReactMarkdown: FC = memo( 5 | ReactMarkdown, 6 | (prevProps, nextProps) => 7 | prevProps.children === nextProps.children && 8 | prevProps.className === nextProps.className 9 | ) 10 | -------------------------------------------------------------------------------- /components/prompt-form.tsx: -------------------------------------------------------------------------------- 1 | import { UseChatHelpers } from 'ai/react' 2 | import * as React from 'react' 3 | import Textarea from 'react-textarea-autosize' 4 | 5 | import { Button, buttonVariants } from '@/components/ui/button' 6 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons' 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipTrigger 11 | } from '@/components/ui/tooltip' 12 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' 13 | import { cn } from '@/lib/utils' 14 | import { useRouter } from 'next/navigation' 15 | 16 | export interface PromptProps 17 | extends Pick { 18 | onSubmit: (value: string) => Promise 19 | isLoading: boolean 20 | } 21 | 22 | export function PromptForm({ 23 | onSubmit, 24 | input, 25 | setInput, 26 | isLoading 27 | }: PromptProps) { 28 | const { formRef, onKeyDown } = useEnterSubmit() 29 | const inputRef = React.useRef(null) 30 | const router = useRouter() 31 | 32 | React.useEffect(() => { 33 | if (inputRef.current) { 34 | inputRef.current.focus() 35 | } 36 | }, []) 37 | 38 | return ( 39 |
{ 41 | e.preventDefault() 42 | if (!input?.trim()) { 43 | return 44 | } 45 | setInput('') 46 | await onSubmit(input) 47 | }} 48 | ref={formRef} 49 | > 50 |
51 | 52 | 53 | 67 | 68 | New Chat 69 | 70 |