├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── deploy-on-main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── (chat) │ ├── chat │ │ └── [id] │ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── actions.ts ├── api │ └── status │ │ └── route.ts ├── globals.css ├── layout.tsx ├── login │ ├── actions.ts │ └── page.tsx ├── new │ └── page.tsx ├── opengraph-image.png ├── share │ └── [id] │ │ └── page.tsx ├── signup │ ├── actions.ts │ └── page.tsx ├── twitter-image.png └── up │ └── route.ts ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── button-scroll-to-bottom.tsx ├── chat-history.tsx ├── chat-list.tsx ├── chat-message-actions.tsx ├── chat-message.tsx ├── chat-panel.tsx ├── chat-share-dialog.tsx ├── chat.tsx ├── clear-history.tsx ├── empty-screen.tsx ├── external-link.tsx ├── footer.tsx ├── header.tsx ├── login-button.tsx ├── login-form.tsx ├── markdown.tsx ├── prompt-form.tsx ├── providers.tsx ├── sidebar-actions.tsx ├── sidebar-desktop.tsx ├── sidebar-footer.tsx ├── sidebar-item.tsx ├── sidebar-items.tsx ├── sidebar-list.tsx ├── sidebar-mobile.tsx ├── sidebar-toggle.tsx ├── sidebar.tsx ├── signup-form.tsx ├── stocks │ ├── events-skeleton.tsx │ ├── events.tsx │ ├── index.tsx │ ├── message.tsx │ ├── spinner.tsx │ ├── stock-purchase.tsx │ ├── stock-skeleton.tsx │ ├── stock.tsx │ ├── stocks-skeleton.tsx │ └── stocks.tsx ├── tailwind-indicator.tsx ├── theme-toggle.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 │ ├── sonner.tsx │ ├── switch.tsx │ ├── textarea.tsx │ └── tooltip.tsx └── user-menu.tsx ├── config └── deploy.yml ├── lib ├── chat │ └── actions.tsx ├── hooks │ ├── use-copy-to-clipboard.tsx │ ├── use-enter-submit.tsx │ ├── use-local-storage.ts │ ├── use-scroll-anchor.tsx │ ├── use-sidebar.tsx │ └── use-streamable-text.ts ├── 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.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm - debug.log 5 | README.md 6 | .next 7 | .git 8 | .gitignore -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | "tailwindcss/classnames-order": "off" 13 | }, 14 | "settings": { 15 | "tailwindcss": { 16 | "callees": ["cn", "cva"], 17 | "config": "tailwind.config.js" 18 | } 19 | }, 20 | "overrides": [ 21 | { 22 | "files": ["*.ts", "*.tsx"], 23 | "parser": "@typescript-eslint/parser" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy-on-main.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | Deploy: 10 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | DOCKER_BUILDKIT: 1 15 | KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} 16 | KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }} 17 | VPS_IP: ${{ secrets.VPS_IP }} 18 | # Use the same ssh-agent socket value across all jobs 19 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 20 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 21 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Ruby 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: 3.2.2 31 | bundler-cache: true 32 | 33 | - name: Install dependencies 34 | run: | 35 | gem install specific_install 36 | gem specific_install https://github.com/basecamp/kamal.git 37 | 38 | - name: Setup SSH with a passphrase 39 | env: 40 | SSH_PASSPHRASE: ${{secrets.SSH_PASSPHRASE}} 41 | SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} 42 | run: | 43 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 44 | echo "echo $SSH_PASSPHRASE" > ~/.ssh_askpass && chmod +x ~/.ssh_askpass 45 | echo "$SSH_PRIVATE_KEY" | tr -d '\r' | DISPLAY=None SSH_ASKPASS=~/.ssh_askpass ssh-add - >/dev/null 46 | 47 | # - uses: webfactory/ssh-agent@v0.7.0 48 | # with: 49 | # ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 50 | 51 | - name: Set up Docker Buildx 52 | id: buildx 53 | uses: docker/setup-buildx-action@v2 54 | 55 | - name: Run deploy command 56 | run: kamal deploy 57 | -------------------------------------------------------------------------------- /.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 | .env 36 | .vercel 37 | .vscode 38 | .env*.local 39 | 40 | groups.json 41 | items.json 42 | deploy.sh 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | # Disabling Telemetry 4 | ENV NEXT_TELEMETRY_DISABLED 1 5 | RUN apk add --no-cache libc6-compat curl python3 py3-pip 6 | 7 | RUN npm install pnpm -g 8 | 9 | FROM base AS deps 10 | WORKDIR /app 11 | 12 | # add env vars as config/deploy.yml is not working 13 | RUN echo "OPENAI_API_KEY=\"$OPENAI_API_KEY\"" > ./env 14 | RUN echo "AUTH_SECRET=\"$AUTH_SECRET\"" >> ./env 15 | 16 | COPY package.json pnpm-lock.yaml ./ 17 | RUN pnpm install 18 | 19 | FROM base AS builder 20 | WORKDIR /app 21 | COPY --from=deps /app/node_modules ./node_modules 22 | COPY . . 23 | 24 | RUN pnpm run build 25 | 26 | FROM base AS runner 27 | WORKDIR /app 28 | 29 | ENV NODE_ENV production 30 | 31 | RUN addgroup --system --gid 1001 nodejs 32 | RUN adduser --system --uid 1001 nextjs 33 | 34 | COPY --from=builder /app/public ./public 35 | RUN mkdir .next 36 | RUN mkdir -p /ai-chatbot-data && chown -R nextjs:nodejs /ai-chatbot-data 37 | RUN chown nextjs:nodejs .next 38 | 39 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 40 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 41 | 42 | USER nextjs 43 | 44 | EXPOSE 3000 45 | 46 | ENV PORT 3000 47 | ENV HOSTNAME "0.0.0.0" 48 | ENV NODE_ENV=production 49 | 50 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /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 14 and App 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 in memory & JSON file cache on a VPS. 8 |

9 | 10 |

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

17 |
18 | 19 | ## Features 20 | 21 | - DOES NOT run on Edge, runs on a €3.30/mo VM on Hetzner in Germany 🇩🇪 22 | - [Next.js](https://nextjs.org) App Router 23 | - React Server Components (RSCs), Suspense, and Server Actions 24 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI 25 | - Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain 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)~~ [local JSON files and in memory store](./lib/storage.ts) 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), [Cohere](https://cohere.com/), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. 36 | 37 | ## Running locally 38 | 39 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. 40 | 41 | > 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. 42 | 43 | ```bash 44 | pnpm install 45 | pnpm dev 46 | ``` 47 | 48 | Your app template should now be running on [localhost:3000](http://localhost:3000/). 49 | 50 | ## Authors 51 | 52 | Hacked by Ashle Rudland ([@ashleyrudland](https://twitter.com/ashleyrudland)) to run on a VPS. 53 | 54 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: 55 | 56 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) 57 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) 58 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) 59 | -------------------------------------------------------------------------------- /app/(chat)/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, getMissingKeys } from '@/app/actions' 6 | import { Chat } from '@/components/chat' 7 | import { AI } from '@/lib/chat/actions' 8 | import { Session } from '@/lib/types' 9 | 10 | export interface ChatPageProps { 11 | params: { 12 | id: string 13 | } 14 | } 15 | 16 | export async function generateMetadata({ 17 | params 18 | }: ChatPageProps): Promise { 19 | const session = await auth() 20 | 21 | if (!session?.user) { 22 | return {} 23 | } 24 | 25 | const chat = await getChat(params.id, session.user.id) 26 | return { 27 | title: chat?.title.toString().slice(0, 50) ?? 'Chat' 28 | } 29 | } 30 | 31 | export default async function ChatPage({ params }: ChatPageProps) { 32 | const session = (await auth()) as Session 33 | const missingKeys = await getMissingKeys() 34 | 35 | if (!session?.user) { 36 | redirect(`/login?next=/chat/${params.id}`) 37 | } 38 | 39 | const userId = session.user.id as string 40 | const chat = await getChat(params.id, userId) 41 | 42 | if (!chat) { 43 | redirect('/') 44 | } 45 | 46 | if (chat?.userId !== session?.user?.id) { 47 | notFound() 48 | } 49 | 50 | return ( 51 | 52 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/(chat)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarDesktop } from '@/components/sidebar-desktop' 2 | 3 | interface ChatLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default async function ChatLayout({ children }: ChatLayoutProps) { 8 | return ( 9 |
10 | 11 | {children} 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/(chat)/page.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from '@/lib/utils' 2 | import { Chat } from '@/components/chat' 3 | import { AI } from '@/lib/chat/actions' 4 | import { auth } from '@/auth' 5 | import { Session } from '@/lib/types' 6 | import { getMissingKeys } from '../actions' 7 | 8 | export const metadata = { 9 | title: 'Next.js AI Chatbot' 10 | } 11 | 12 | export default async function IndexPage() { 13 | const id = nanoid() 14 | const session = (await auth()) as Session 15 | const missingKeys = await getMissingKeys() 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /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 | import { 10 | deleteChatById, 11 | deleteChatsByUserId, 12 | getChatById, 13 | getChatsByUserId, 14 | insertChat, 15 | updateChat 16 | } from '@/lib/storage' 17 | 18 | export async function getChats(userId?: string | null) { 19 | if (!userId) { 20 | return [] 21 | } 22 | 23 | try { 24 | return getChatsByUserId(userId) 25 | } catch (error) { 26 | return [] 27 | } 28 | } 29 | 30 | export async function getChat(id: string, userId: string) { 31 | const chat = await getChatById(id) 32 | 33 | if (!chat || (userId && chat.userId !== userId)) { 34 | return null 35 | } 36 | 37 | return chat 38 | } 39 | 40 | export async function removeChat({ id, path }: { id: string; path: string }) { 41 | const session = await auth() 42 | 43 | if (!session) { 44 | return { 45 | error: 'Unauthorized' 46 | } 47 | } 48 | 49 | //Convert uid to string for consistent comparison with session.user.id 50 | const uid = (await getChatById(id))?.userId 51 | 52 | if (uid !== session?.user?.id) { 53 | return { 54 | error: 'Unauthorized' 55 | } 56 | } 57 | 58 | await deleteChatById(id) 59 | 60 | revalidatePath('/') 61 | return revalidatePath(path) 62 | } 63 | 64 | export async function clearChats() { 65 | const session = await auth() 66 | 67 | if (!session?.user?.id) { 68 | return { 69 | error: 'Unauthorized' 70 | } 71 | } 72 | 73 | await deleteChatsByUserId(session.user.id) 74 | 75 | revalidatePath('/') 76 | return redirect('/') 77 | } 78 | 79 | export async function getSharedChat(id: string) { 80 | const chat = await getChatById(id) 81 | 82 | if (!chat || !chat.sharePath) { 83 | return null 84 | } 85 | 86 | return chat 87 | } 88 | 89 | export async function shareChat(id: string) { 90 | const session = await auth() 91 | 92 | if (!session?.user?.id) { 93 | return { 94 | error: 'Unauthorized' 95 | } 96 | } 97 | 98 | const chat = await getChatById(id) 99 | 100 | if (!chat || chat.userId !== session.user.id) { 101 | return { 102 | error: 'Something went wrong' 103 | } 104 | } 105 | 106 | const payload = { 107 | ...chat, 108 | sharePath: `/share/${chat.id}` 109 | } 110 | 111 | await updateChat(id, payload) 112 | 113 | return payload 114 | } 115 | 116 | export async function saveChat(chat: Chat) { 117 | const session = await auth() 118 | 119 | if (session && session.user) { 120 | await insertChat(chat) 121 | } else { 122 | return 123 | } 124 | } 125 | 126 | export async function refreshHistory(path: string) { 127 | redirect(path) 128 | } 129 | 130 | export async function getMissingKeys() { 131 | const keysRequired = ['OPENAI_API_KEY'] 132 | return keysRequired 133 | .map(key => (process.env[key] ? '' : key)) 134 | .filter(key => key !== '') 135 | } 136 | -------------------------------------------------------------------------------- /app/api/status/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers' 2 | import { NextResponse } from 'next/server' 3 | 4 | import { getStats } from '@/lib/storage' 5 | 6 | export async function GET() { 7 | // stay dynamic 8 | cookies().get('token') 9 | 10 | const stats = await getStats() 11 | return NextResponse.json(stats) 12 | } 13 | -------------------------------------------------------------------------------- /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 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GeistSans } from 'geist/font/sans' 2 | import { GeistMono } from 'geist/font/mono' 3 | 4 | import '@/app/globals.css' 5 | import { cn } from '@/lib/utils' 6 | import { TailwindIndicator } from '@/components/tailwind-indicator' 7 | import { Providers } from '@/components/providers' 8 | import { Header } from '@/components/header' 9 | import { Toaster } from '@/components/ui/sonner' 10 | 11 | export const metadata = { 12 | metadataBase: process.env.VERCEL_URL 13 | ? new URL(`https://${process.env.VERCEL_URL}`) 14 | : undefined, 15 | title: { 16 | default: 'Next.js AI Chatbot', 17 | template: `%s - Next.js AI Chatbot` 18 | }, 19 | description: 'An AI-powered chatbot template built with Next.js and Vercel.', 20 | icons: { 21 | icon: '/favicon.ico', 22 | shortcut: '/favicon-16x16.png', 23 | apple: '/apple-touch-icon.png' 24 | } 25 | } 26 | 27 | export const viewport = { 28 | themeColor: [ 29 | { media: '(prefers-color-scheme: light)', color: 'white' }, 30 | { media: '(prefers-color-scheme: dark)', color: 'black' } 31 | ] 32 | } 33 | 34 | interface RootLayoutProps { 35 | children: React.ReactNode 36 | } 37 | 38 | export default function RootLayout({ children }: RootLayoutProps) { 39 | return ( 40 | 41 | 48 | 49 | 55 |
56 |
57 |
{children}
58 |
59 | 60 |
61 | 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/login/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { signIn } from '@/auth' 4 | import { AuthError } from 'next-auth' 5 | import { z } from 'zod' 6 | import { ResultCode } from '@/lib/utils' 7 | import { getUserByEmail } from '@/lib/storage' 8 | 9 | export async function getUser(email: string) { 10 | const user = await getUserByEmail(email) 11 | 12 | console.log('user', user) 13 | 14 | return user 15 | } 16 | 17 | interface Result { 18 | type: string 19 | resultCode: ResultCode 20 | } 21 | 22 | export async function authenticate( 23 | _prevState: Result | undefined, 24 | formData: FormData 25 | ): Promise { 26 | try { 27 | const email = formData.get('email') 28 | const password = formData.get('password') 29 | 30 | const parsedCredentials = z 31 | .object({ 32 | email: z.string().email(), 33 | password: z.string().min(6) 34 | }) 35 | .safeParse({ 36 | email, 37 | password 38 | }) 39 | 40 | if (parsedCredentials.success) { 41 | await signIn('credentials', { 42 | email, 43 | password, 44 | redirect: false 45 | }) 46 | 47 | return { 48 | type: 'success', 49 | resultCode: ResultCode.UserLoggedIn 50 | } 51 | } else { 52 | return { 53 | type: 'error', 54 | resultCode: ResultCode.InvalidCredentials 55 | } 56 | } 57 | } catch (error) { 58 | if (error instanceof AuthError) { 59 | switch (error.type) { 60 | case 'CredentialsSignin': 61 | return { 62 | type: 'error', 63 | resultCode: ResultCode.InvalidCredentials 64 | } 65 | default: 66 | return { 67 | type: 'error', 68 | resultCode: ResultCode.UnknownError 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import LoginForm from '@/components/login-form' 3 | import { Session } from '@/lib/types' 4 | import { redirect } from 'next/navigation' 5 | 6 | export default async function LoginPage() { 7 | const session = (await auth()) as Session 8 | 9 | if (session) { 10 | redirect('/') 11 | } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | export default async function NewPage() { 4 | redirect('/') 5 | } 6 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/app/opengraph-image.png -------------------------------------------------------------------------------- /app/share/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next' 2 | import { notFound, redirect } 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 | import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions' 9 | 10 | interface SharePageProps { 11 | params: { 12 | id: string 13 | } 14 | } 15 | 16 | export async function generateMetadata({ 17 | params 18 | }: SharePageProps): Promise { 19 | const chat = await getSharedChat(params.id) 20 | 21 | return { 22 | title: chat?.title.slice(0, 50) ?? 'Chat' 23 | } 24 | } 25 | 26 | export default async function SharePage({ params }: SharePageProps) { 27 | const chat = await getSharedChat(params.id) 28 | 29 | if (!chat || !chat?.sharePath) { 30 | notFound() 31 | } 32 | 33 | const uiState: UIState = getUIStateFromAIState(chat) 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 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/signup/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { signIn } from '@/auth' 4 | import { ResultCode, getStringFromBuffer } from '@/lib/utils' 5 | import { z } from 'zod' 6 | import { getUser } from '../login/actions' 7 | import { AuthError } from 'next-auth' 8 | import { insertUser } from '@/lib/storage' 9 | 10 | export async function createUser( 11 | email: string, 12 | hashedPassword: string, 13 | salt: string 14 | ) { 15 | const existingUser = await getUser(email) 16 | 17 | if (existingUser) { 18 | return { 19 | type: 'error', 20 | resultCode: ResultCode.UserAlreadyExists 21 | } 22 | } else { 23 | const user = { 24 | id: crypto.randomUUID(), 25 | email, 26 | password: hashedPassword, 27 | salt 28 | } 29 | 30 | insertUser(user) 31 | 32 | return { 33 | type: 'success', 34 | resultCode: ResultCode.UserCreated 35 | } 36 | } 37 | } 38 | 39 | interface Result { 40 | type: string 41 | resultCode: ResultCode 42 | } 43 | 44 | export async function signup( 45 | _prevState: Result | undefined, 46 | formData: FormData 47 | ): Promise { 48 | const email = formData.get('email') as string 49 | const password = formData.get('password') as string 50 | 51 | const parsedCredentials = z 52 | .object({ 53 | email: z.string().email(), 54 | password: z.string().min(6) 55 | }) 56 | .safeParse({ 57 | email, 58 | password 59 | }) 60 | 61 | if (parsedCredentials.success) { 62 | const salt = crypto.randomUUID() 63 | 64 | const encoder = new TextEncoder() 65 | const saltedPassword = encoder.encode(password + salt) 66 | const hashedPasswordBuffer = await crypto.subtle.digest( 67 | 'SHA-256', 68 | saltedPassword 69 | ) 70 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) 71 | 72 | try { 73 | const result = await createUser(email, hashedPassword, salt) 74 | 75 | if (result.resultCode === ResultCode.UserCreated) { 76 | await signIn('credentials', { 77 | email, 78 | password, 79 | redirect: false 80 | }) 81 | } 82 | 83 | return result 84 | } catch (error) { 85 | if (error instanceof AuthError) { 86 | switch (error.type) { 87 | case 'CredentialsSignin': 88 | return { 89 | type: 'error', 90 | resultCode: ResultCode.InvalidCredentials 91 | } 92 | default: 93 | return { 94 | type: 'error', 95 | resultCode: ResultCode.UnknownError 96 | } 97 | } 98 | } else { 99 | return { 100 | type: 'error', 101 | resultCode: ResultCode.UnknownError 102 | } 103 | } 104 | } 105 | } else { 106 | return { 107 | type: 'error', 108 | resultCode: ResultCode.InvalidCredentials 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import SignupForm from '@/components/signup-form' 3 | import { Session } from '@/lib/types' 4 | import { redirect } from 'next/navigation' 5 | 6 | export default async function SignupPage() { 7 | const session = (await auth()) as Session 8 | 9 | if (session) { 10 | redirect('/') 11 | } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/app/twitter-image.png -------------------------------------------------------------------------------- /app/up/route.ts: -------------------------------------------------------------------------------- 1 | export async function GET() { 2 | return new Response('Ok', { status: 200 }) 3 | } 4 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth' 2 | 3 | export const authConfig = { 4 | trustHost: true, 5 | secret: process.env.AUTH_SECRET, 6 | pages: { 7 | signIn: '/login', 8 | newUser: '/signup' 9 | }, 10 | callbacks: { 11 | async authorized({ auth, request: { nextUrl } }) { 12 | const isLoggedIn = !!auth?.user 13 | const isOnLoginPage = nextUrl.pathname.startsWith('/login') 14 | const isOnSignupPage = nextUrl.pathname.startsWith('/signup') 15 | 16 | if (isLoggedIn) { 17 | if (isOnLoginPage || isOnSignupPage) { 18 | return Response.redirect(new URL('/', nextUrl)) 19 | } 20 | } 21 | 22 | return true 23 | }, 24 | async jwt({ token, user }) { 25 | if (user) { 26 | token = { ...token, id: user.id } 27 | } 28 | 29 | return token 30 | }, 31 | async session({ session, token }) { 32 | if (token) { 33 | const { id } = token as { id: string } 34 | const { user } = session 35 | 36 | session = { ...session, user: { ...user, id } } 37 | } 38 | 39 | return session 40 | } 41 | }, 42 | providers: [] 43 | } satisfies NextAuthConfig 44 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import Credentials from 'next-auth/providers/credentials' 3 | import { authConfig } from './auth.config' 4 | import { z } from 'zod' 5 | import { getStringFromBuffer } from './lib/utils' 6 | import { getUser } from './app/login/actions' 7 | 8 | export const { auth, signIn, signOut } = NextAuth({ 9 | ...authConfig, 10 | providers: [ 11 | Credentials({ 12 | async authorize(credentials) { 13 | const parsedCredentials = z 14 | .object({ 15 | email: z.string().email(), 16 | password: z.string().min(6) 17 | }) 18 | .safeParse(credentials) 19 | 20 | if (parsedCredentials.success) { 21 | const { email, password } = parsedCredentials.data 22 | const user = await getUser(email) 23 | 24 | if (!user) return null 25 | 26 | const encoder = new TextEncoder() 27 | const saltedPassword = encoder.encode(password + user.salt) 28 | const hashedPasswordBuffer = await crypto.subtle.digest( 29 | 'SHA-256', 30 | saltedPassword 31 | ) 32 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) 33 | 34 | if (hashedPassword === user.password) { 35 | return user 36 | } else { 37 | return null 38 | } 39 | } 40 | 41 | return null 42 | } 43 | }) 44 | ] 45 | }) 46 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /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 { Button, type ButtonProps } from '@/components/ui/button' 7 | import { IconArrowDown } from '@/components/ui/icons' 8 | 9 | interface ButtonScrollToBottomProps extends ButtonProps { 10 | isAtBottom: boolean 11 | scrollToBottom: () => void 12 | } 13 | 14 | export function ButtonScrollToBottom({ 15 | className, 16 | isAtBottom, 17 | scrollToBottom, 18 | ...props 19 | }: ButtonScrollToBottomProps) { 20 | return ( 21 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/chat-history.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import Link from 'next/link' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { SidebarList } from '@/components/sidebar-list' 7 | import { buttonVariants } from '@/components/ui/button' 8 | import { IconPlus } from '@/components/ui/icons' 9 | 10 | interface ChatHistoryProps { 11 | userId?: string 12 | } 13 | 14 | export async function ChatHistory({ userId }: ChatHistoryProps) { 15 | return ( 16 |
17 |
18 |

Chat History

19 |
20 |
21 | 28 | 29 | New Chat 30 | 31 |
32 | 35 | {Array.from({ length: 10 }).map((_, i) => ( 36 |
40 | ))} 41 |
42 | } 43 | > 44 | {/* @ts-ignore */} 45 | 46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/chat-list.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from '@/components/ui/separator' 2 | import { UIState } from '@/lib/chat/actions' 3 | import { Session } from '@/lib/types' 4 | import Link from 'next/link' 5 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons' 6 | 7 | export interface ChatList { 8 | messages: UIState 9 | session?: Session 10 | isShared: boolean 11 | } 12 | 13 | export function ChatList({ messages, session, isShared }: ChatList) { 14 | if (!messages.length) { 15 | return null 16 | } 17 | 18 | return ( 19 |
20 | {!isShared && !session ? ( 21 | <> 22 |
23 |
24 | 25 |
26 |
27 |

28 | Please{' '} 29 | 30 | log in 31 | {' '} 32 | or{' '} 33 | 34 | sign up 35 | {' '} 36 | to save and revisit your chat history! 37 |

38 |
39 |
40 | 41 | 42 | ) : null} 43 | 44 | {messages.map((message, index) => ( 45 |
46 | {message.display} 47 | {index < messages.length - 1 && } 48 |
49 | ))} 50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /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 * as React from 'react' 2 | 3 | import { shareChat } from '@/app/actions' 4 | import { Button } from '@/components/ui/button' 5 | import { PromptForm } from '@/components/prompt-form' 6 | import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' 7 | import { IconShare } from '@/components/ui/icons' 8 | import { FooterText } from '@/components/footer' 9 | import { ChatShareDialog } from '@/components/chat-share-dialog' 10 | import { useAIState, useActions, useUIState } from 'ai/rsc' 11 | import type { AI } from '@/lib/chat/actions' 12 | import { nanoid } from 'nanoid' 13 | import { UserMessage } from './stocks/message' 14 | 15 | export interface ChatPanelProps { 16 | id?: string 17 | title?: string 18 | input: string 19 | setInput: (value: string) => void 20 | isAtBottom: boolean 21 | scrollToBottom: () => void 22 | } 23 | 24 | export function ChatPanel({ 25 | id, 26 | title, 27 | input, 28 | setInput, 29 | isAtBottom, 30 | scrollToBottom 31 | }: ChatPanelProps) { 32 | const [aiState] = useAIState() 33 | const [messages, setMessages] = useUIState() 34 | const { submitUserMessage } = useActions() 35 | const [shareDialogOpen, setShareDialogOpen] = React.useState(false) 36 | 37 | const exampleMessages = [ 38 | { 39 | heading: 'What are the', 40 | subheading: 'trending memecoins today?', 41 | message: `What are the trending memecoins today?` 42 | }, 43 | { 44 | heading: 'What is the price of', 45 | subheading: '$DOGE right now?', 46 | message: 'What is the price of $DOGE right now?' 47 | }, 48 | { 49 | heading: 'I would like to buy', 50 | subheading: '42 $DOGE', 51 | message: `I would like to buy 42 $DOGE` 52 | }, 53 | { 54 | heading: 'What are some', 55 | subheading: `recent events about $DOGE?`, 56 | message: `What are some recent events about $DOGE?` 57 | } 58 | ] 59 | 60 | return ( 61 |
62 | 66 | 67 |
68 |
69 | {messages.length === 0 && 70 | exampleMessages.map((example, index) => ( 71 |
1 && 'hidden md:block' 75 | }`} 76 | onClick={async () => { 77 | setMessages(currentMessages => [ 78 | ...currentMessages, 79 | { 80 | id: nanoid(), 81 | display: {example.message} 82 | } 83 | ]) 84 | 85 | const responseMessage = await submitUserMessage( 86 | example.message 87 | ) 88 | 89 | setMessages(currentMessages => [ 90 | ...currentMessages, 91 | responseMessage 92 | ]) 93 | }} 94 | > 95 |
{example.heading}
96 |
97 | {example.subheading} 98 |
99 |
100 | ))} 101 |
102 | 103 | {messages?.length >= 2 ? ( 104 |
105 |
106 | {id && title ? ( 107 | <> 108 | 115 | setShareDialogOpen(false)} 119 | shareChat={shareChat} 120 | chat={{ 121 | id, 122 | title, 123 | messages: aiState.messages 124 | }} 125 | /> 126 | 127 | ) : null} 128 |
129 |
130 | ) : null} 131 | 132 |
133 | 134 | 135 |
136 |
137 |
138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /components/chat-share-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { type DialogProps } from '@radix-ui/react-dialog' 5 | import { toast } from 'sonner' 6 | 7 | import { ServerActionResult, type Chat } from '@/lib/types' 8 | import { Button } from '@/components/ui/button' 9 | import { 10 | Dialog, 11 | DialogContent, 12 | DialogDescription, 13 | DialogFooter, 14 | DialogHeader, 15 | DialogTitle 16 | } from '@/components/ui/dialog' 17 | import { IconSpinner } from '@/components/ui/icons' 18 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' 19 | 20 | interface ChatShareDialogProps extends DialogProps { 21 | chat: Pick 22 | shareChat: (id: string) => ServerActionResult 23 | onCopy: () => void 24 | } 25 | 26 | export function ChatShareDialog({ 27 | chat, 28 | shareChat, 29 | onCopy, 30 | ...props 31 | }: ChatShareDialogProps) { 32 | const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) 33 | const [isSharePending, startShareTransition] = React.useTransition() 34 | 35 | const copyShareLink = React.useCallback( 36 | async (chat: Chat) => { 37 | if (!chat.sharePath) { 38 | return toast.error('Could not copy share link to clipboard') 39 | } 40 | 41 | const url = new URL(window.location.href) 42 | url.pathname = chat.sharePath 43 | copyToClipboard(url.toString()) 44 | onCopy() 45 | toast.success('Share link copied to clipboard') 46 | }, 47 | [copyToClipboard, onCopy] 48 | ) 49 | 50 | return ( 51 | 52 | 53 | 54 | Share link to chat 55 | 56 | Anyone with the URL will be able to view the shared chat. 57 | 58 | 59 |
60 |
{chat.title}
61 |
62 | {chat.messages.length} messages 63 |
64 |
65 | 66 | 91 | 92 |
93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { ChatList } from '@/components/chat-list' 5 | import { ChatPanel } from '@/components/chat-panel' 6 | import { EmptyScreen } from '@/components/empty-screen' 7 | import { useLocalStorage } from '@/lib/hooks/use-local-storage' 8 | import { useEffect, useState } from 'react' 9 | import { useUIState, useAIState } from 'ai/rsc' 10 | import { Session } from '@/lib/types' 11 | import { usePathname, useRouter } from 'next/navigation' 12 | import { Message } from '@/lib/chat/actions' 13 | import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor' 14 | import { toast } from 'sonner' 15 | 16 | export interface ChatProps extends React.ComponentProps<'div'> { 17 | initialMessages?: Message[] 18 | id?: string 19 | session?: Session 20 | missingKeys: string[] 21 | } 22 | 23 | export function Chat({ id, className, session, missingKeys }: ChatProps) { 24 | const router = useRouter() 25 | const path = usePathname() 26 | const [input, setInput] = useState('') 27 | const [messages] = useUIState() 28 | const [aiState] = useAIState() 29 | 30 | const [_, setNewChatId] = useLocalStorage('newChatId', id) 31 | 32 | useEffect(() => { 33 | if (session?.user) { 34 | if (!path.includes('chat') && messages.length === 1) { 35 | window.history.replaceState({}, '', `/chat/${id}`) 36 | } 37 | } 38 | }, [id, path, session?.user, messages]) 39 | 40 | useEffect(() => { 41 | const messagesLength = aiState.messages?.length 42 | if (messagesLength === 2) { 43 | router.refresh() 44 | } 45 | }, [aiState.messages, router]) 46 | 47 | useEffect(() => { 48 | setNewChatId(id) 49 | }) 50 | 51 | useEffect(() => { 52 | missingKeys.map(key => { 53 | toast.error(`Missing ${key} environment variable!`) 54 | }) 55 | }, [missingKeys]) 56 | 57 | const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } = 58 | useScrollAnchor() 59 | 60 | return ( 61 |
65 |
69 | {messages.length ? ( 70 | 71 | ) : ( 72 | 73 | )} 74 |
75 |
76 | 83 |
84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /components/clear-history.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useRouter } from 'next/navigation' 5 | import { toast } from 'sonner' 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 | isEnabled: boolean 24 | clearChats: () => ServerActionResult 25 | } 26 | 27 | export function ClearHistory({ 28 | isEnabled = false, 29 | clearChats 30 | }: ClearHistoryProps) { 31 | const [open, setOpen] = React.useState(false) 32 | const [isPending, startTransition] = React.useTransition() 33 | const router = useRouter() 34 | 35 | return ( 36 | 37 | 38 | 42 | 43 | 44 | 45 | Are you absolutely sure? 46 | 47 | This will permanently delete your chat history and remove your data 48 | from our servers. 49 | 50 | 51 | 52 | Cancel 53 | { 56 | event.preventDefault() 57 | startTransition(async () => { 58 | const result = await clearChats() 59 | if (result && 'error' in result) { 60 | toast.error(result.error) 61 | return 62 | } 63 | 64 | setOpen(false) 65 | }) 66 | }} 67 | > 68 | {isPending && } 69 | Delete 70 | 71 | 72 | 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /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() { 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, the{' '} 32 | 33 | Vercel AI SDK 34 | 35 | , and{' '} 36 | 37 | Vercel KV 38 | 39 | . 40 |

41 |

42 | It uses{' '} 43 | 44 | React Server Components 45 | {' '} 46 | to combine text with generative UI as output of the LLM. The UI state 47 | is synced through the SDK so the model is aware of your interactions 48 | as they happen. 49 |

50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /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 AI SDK 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 { Button, buttonVariants } from '@/components/ui/button' 7 | import { 8 | IconGitHub, 9 | IconNextChat, 10 | IconSeparator, 11 | IconVercel 12 | } from '@/components/ui/icons' 13 | import { UserMenu } from '@/components/user-menu' 14 | import { SidebarMobile } from './sidebar-mobile' 15 | import { SidebarToggle } from './sidebar-toggle' 16 | import { ChatHistory } from './chat-history' 17 | import { Session } from '@/lib/types' 18 | 19 | async function UserOrLogin() { 20 | const session = (await auth()) as Session 21 | return ( 22 | <> 23 | {session?.user ? ( 24 | <> 25 | 26 | 27 | 28 | 29 | 30 | ) : ( 31 | 32 | 33 | 34 | 35 | )} 36 |
37 | 38 | {session?.user ? ( 39 | 40 | ) : ( 41 | 44 | )} 45 |
46 | 47 | ) 48 | } 49 | 50 | export function Header() { 51 | return ( 52 |
53 |
54 | }> 55 | 56 | 57 |
58 | 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /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/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useFormState, useFormStatus } from 'react-dom' 4 | import { authenticate } from '@/app/login/actions' 5 | import Link from 'next/link' 6 | import { useEffect } from 'react' 7 | import { toast } from 'sonner' 8 | import { IconSpinner } from './ui/icons' 9 | import { getMessageFromCode } from '@/lib/utils' 10 | import { useRouter } from 'next/navigation' 11 | 12 | export default function LoginForm() { 13 | const router = useRouter() 14 | const [result, dispatch] = useFormState(authenticate, undefined) 15 | 16 | useEffect(() => { 17 | if (result) { 18 | if (result.type === 'error') { 19 | toast.error(getMessageFromCode(result.resultCode)) 20 | } else { 21 | toast.success(getMessageFromCode(result.resultCode)) 22 | router.refresh() 23 | } 24 | } 25 | }, [result, router]) 26 | 27 | return ( 28 |
32 |
33 |

Please log in to continue.

34 |
35 |
36 | 42 |
43 | 51 |
52 |
53 |
54 | 60 |
61 | 70 |
71 |
72 |
73 | 74 |
75 | 76 | 80 | No account yet?
Sign up
81 | 82 |
83 | ) 84 | } 85 | 86 | function LoginButton() { 87 | const { pending } = useFormStatus() 88 | 89 | return ( 90 | 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /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 | 'use client' 2 | 3 | import * as React from 'react' 4 | import Textarea from 'react-textarea-autosize' 5 | 6 | import { useActions, useUIState } from 'ai/rsc' 7 | 8 | import { UserMessage } from './stocks/message' 9 | import { type AI } from '@/lib/chat/actions' 10 | import { Button } from '@/components/ui/button' 11 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons' 12 | import { 13 | Tooltip, 14 | TooltipContent, 15 | TooltipTrigger 16 | } from '@/components/ui/tooltip' 17 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' 18 | import { nanoid } from 'nanoid' 19 | import { useRouter } from 'next/navigation' 20 | 21 | export function PromptForm({ 22 | input, 23 | setInput 24 | }: { 25 | input: string 26 | setInput: (value: string) => void 27 | }) { 28 | const router = useRouter() 29 | const { formRef, onKeyDown } = useEnterSubmit() 30 | const inputRef = React.useRef(null) 31 | const { submitUserMessage } = useActions() 32 | const [_, setMessages] = useUIState() 33 | 34 | React.useEffect(() => { 35 | if (inputRef.current) { 36 | inputRef.current.focus() 37 | } 38 | }, []) 39 | 40 | return ( 41 |
{ 44 | e.preventDefault() 45 | 46 | // Blur focus on mobile 47 | if (window.innerWidth < 600) { 48 | e.target['message']?.blur() 49 | } 50 | 51 | const value = input.trim() 52 | setInput('') 53 | if (!value) return 54 | 55 | // Optimistically add user message UI 56 | setMessages(currentMessages => [ 57 | ...currentMessages, 58 | { 59 | id: nanoid(), 60 | display: {value} 61 | } 62 | ]) 63 | 64 | // Submit and get response message 65 | const responseMessage = await submitUserMessage(value) 66 | setMessages(currentMessages => [...currentMessages, responseMessage]) 67 | }} 68 | > 69 |
70 | 71 | 72 | 83 | 84 | New Chat 85 | 86 |