├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── lint.yml │ └── playwright.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── app ├── (auth) │ ├── actions.ts │ ├── api │ │ └── auth │ │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ │ └── guest │ │ │ └── route.ts │ ├── auth.config.ts │ ├── auth.ts │ ├── login │ │ └── page.tsx │ └── register │ │ └── page.tsx ├── (chat) │ ├── actions.ts │ ├── api │ │ ├── chat │ │ │ ├── route.ts │ │ │ └── schema.ts │ │ ├── document │ │ │ └── route.ts │ │ ├── files │ │ │ └── upload │ │ │ │ └── route.ts │ │ ├── history │ │ │ └── route.ts │ │ ├── suggestions │ │ │ └── route.ts │ │ └── vote │ │ │ └── route.ts │ ├── chat │ │ └── [id] │ │ │ └── page.tsx │ ├── layout.tsx │ ├── opengraph-image.png │ ├── page.tsx │ └── twitter-image.png ├── favicon.ico ├── globals.css └── layout.tsx ├── artifacts ├── actions.ts ├── code │ ├── client.tsx │ └── server.ts ├── image │ ├── client.tsx │ └── server.ts ├── sheet │ ├── client.tsx │ └── server.ts └── text │ ├── client.tsx │ └── server.ts ├── biome.jsonc ├── components.json ├── components ├── app-sidebar.tsx ├── artifact-actions.tsx ├── artifact-close-button.tsx ├── artifact-messages.tsx ├── artifact.tsx ├── auth-form.tsx ├── chat-header.tsx ├── chat.tsx ├── code-block.tsx ├── code-editor.tsx ├── console.tsx ├── create-artifact.tsx ├── data-stream-handler.tsx ├── diffview.tsx ├── document-preview.tsx ├── document-skeleton.tsx ├── document.tsx ├── greeting.tsx ├── icons.tsx ├── image-editor.tsx ├── markdown.tsx ├── message-actions.tsx ├── message-editor.tsx ├── message-reasoning.tsx ├── message.tsx ├── messages.tsx ├── model-selector.tsx ├── multimodal-input.tsx ├── preview-attachment.tsx ├── sheet-editor.tsx ├── sidebar-history-item.tsx ├── sidebar-history.tsx ├── sidebar-toggle.tsx ├── sidebar-user-nav.tsx ├── sign-out-form.tsx ├── submit-button.tsx ├── suggested-actions.tsx ├── suggestion.tsx ├── text-editor.tsx ├── theme-provider.tsx ├── toast.tsx ├── toolbar.tsx ├── ui │ ├── alert-dialog.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── version-footer.tsx ├── visibility-selector.tsx └── weather.tsx ├── drizzle.config.ts ├── hooks ├── use-artifact.ts ├── use-auto-resume.ts ├── use-chat-visibility.ts ├── use-messages.tsx ├── use-mobile.tsx └── use-scroll-to-bottom.tsx ├── instrumentation.ts ├── lib ├── ai │ ├── entitlements.ts │ ├── models.test.ts │ ├── models.ts │ ├── prompts.ts │ ├── providers.ts │ └── tools │ │ ├── create-document.ts │ │ ├── get-weather.ts │ │ ├── request-suggestions.ts │ │ └── update-document.ts ├── artifacts │ └── server.ts ├── constants.ts ├── db │ ├── helpers │ │ └── 01-core-to-parts.ts │ ├── migrate.ts │ ├── migrations │ │ ├── 0000_keen_devos.sql │ │ ├── 0001_sparkling_blue_marvel.sql │ │ ├── 0002_wandering_riptide.sql │ │ ├── 0003_cloudy_glorian.sql │ │ ├── 0004_odd_slayback.sql │ │ ├── 0005_wooden_whistler.sql │ │ ├── 0006_marvelous_frog_thor.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ └── _journal.json │ ├── queries.ts │ ├── schema.ts │ └── utils.ts ├── editor │ ├── config.ts │ ├── diff.js │ ├── functions.tsx │ ├── react-renderer.tsx │ └── suggestions.tsx ├── errors.ts ├── types.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.ts ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public └── images │ ├── demo-thumbnail.png │ └── mouth of the seine, monet.jpg ├── tailwind.config.ts ├── tests ├── e2e │ ├── artifacts.test.ts │ ├── chat.test.ts │ ├── reasoning.test.ts │ └── session.test.ts ├── fixtures.ts ├── helpers.ts ├── pages │ ├── artifact.ts │ ├── auth.ts │ └── chat.ts ├── prompts │ ├── basic.ts │ ├── routes.ts │ └── utils.ts └── routes │ ├── chat.test.ts │ └── document.test.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 2 | AUTH_SECRET=**** 3 | 4 | # The following keys below are automatically created and 5 | # added to your environment when you deploy on vercel 6 | 7 | # Get your xAI API Key here for chat and image models: https://console.x.ai/ 8 | XAI_API_KEY=**** 9 | 10 | # Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob 11 | BLOB_READ_WRITE_TOKEN=**** 12 | 13 | # Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart 14 | POSTGRES_URL=**** 15 | 16 | 17 | # Instructions to create a Redis store here: 18 | # https://vercel.com/docs/redis 19 | REDIS_URL=**** 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:import/recommended", 5 | "plugin:import/typescript", 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 | "import/resolver": { 16 | "typescript": { 17 | "alwaysTryTypes": true, 18 | "project": "./tsconfig.json" 19 | } 20 | } 21 | }, 22 | "ignorePatterns": ["**/components/ui/**"] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | node-version: [20] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v4 15 | with: 16 | version: 9.12.3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'pnpm' 22 | - name: Install dependencies 23 | run: pnpm install 24 | - name: Run lint 25 | run: pnpm lint -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | 8 | jobs: 9 | test: 10 | timeout-minutes: 30 11 | runs-on: ubuntu-latest 12 | env: 13 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }} 14 | POSTGRES_URL: ${{ secrets.POSTGRES_URL }} 15 | BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} 16 | REDIS_URL: ${{ secrets.REDIS_URL }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 1 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: lts/* 26 | 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v2 29 | with: 30 | version: latest 31 | run_install: false 32 | 33 | - name: Get pnpm store directory 34 | id: pnpm-cache 35 | shell: bash 36 | run: | 37 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 38 | 39 | - uses: actions/cache@v3 40 | with: 41 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: lts/* 49 | cache: "pnpm" 50 | 51 | - name: Install dependencies 52 | run: pnpm install --frozen-lockfile 53 | 54 | - name: Cache Playwright browsers 55 | uses: actions/cache@v3 56 | id: playwright-cache 57 | with: 58 | path: ~/.cache/ms-playwright 59 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} 60 | 61 | - name: Install Playwright Browsers 62 | if: steps.playwright-cache.outputs.cache-hit != 'true' 63 | run: pnpm exec playwright install --with-deps chromium 64 | 65 | - name: Run Playwright tests 66 | run: pnpm test 67 | 68 | - uses: actions/upload-artifact@v4 69 | if: always() && !cancelled() 70 | with: 71 | name: playwright-report 72 | path: playwright-report/ 73 | retention-days: 7 74 | -------------------------------------------------------------------------------- /.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 | .env*.local 38 | 39 | # Playwright 40 | /test-results/ 41 | /playwright-report/ 42 | /blob-report/ 43 | /playwright/* 44 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[typescriptreact]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "typescript.tsdk": "node_modules/typescript/lib", 13 | "eslint.workingDirectories": [ 14 | { "pattern": "app/*" }, 15 | { "pattern": "packages/*" } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 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 |

Chat SDK

4 |
5 | 6 |

7 | Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications. 8 |

9 | 10 |

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

17 |
18 | 19 | ## Features 20 | 21 | - [Next.js](https://nextjs.org) App Router 22 | - Advanced routing for seamless navigation and performance 23 | - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance 24 | - [AI SDK](https://sdk.vercel.ai/docs) 25 | - Unified API for generating text, structured objects, and tool calls with LLMs 26 | - Hooks for building dynamic chat and generative user interfaces 27 | - Supports xAI (default), OpenAI, Fireworks, and other model providers 28 | - [shadcn/ui](https://ui.shadcn.com) 29 | - Styling with [Tailwind CSS](https://tailwindcss.com) 30 | - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility 31 | - Data Persistence 32 | - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data 33 | - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage 34 | - [Auth.js](https://authjs.dev) 35 | - Simple and secure authentication 36 | 37 | ## Model Providers 38 | 39 | This template ships with [xAI](https://x.ai) `grok-2-1212` as the default chat model. However, with the [AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code. 40 | 41 | ## Deploy Your Own 42 | 43 | You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: 44 | 45 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET&envDescription=Learn+more+about+how+to+get+the+API+Keys+for+the+application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI+Chatbot&demo-description=An+Open-Source+AI+Chatbot+Template+Built+With+Next.js+and+the+AI+SDK+by+Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&products=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22ai%22%2C%22productSlug%22%3A%22grok%22%2C%22integrationSlug%22%3A%22xai%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22integrationSlug%22%3A%22upstash%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D) 46 | 47 | ## Running locally 48 | 49 | 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/projects/environment-variables) for this, but a `.env` file is all that is necessary. 50 | 51 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts. 52 | 53 | 1. Install Vercel CLI: `npm i -g vercel` 54 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 55 | 3. Download your environment variables: `vercel env pull` 56 | 57 | ```bash 58 | pnpm install 59 | pnpm dev 60 | ``` 61 | 62 | Your app template should now be running on [localhost:3000](http://localhost:3000). 63 | -------------------------------------------------------------------------------- /app/(auth)/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import { createUser, getUser } from '@/lib/db/queries'; 6 | 7 | import { signIn } from './auth'; 8 | 9 | const authFormSchema = z.object({ 10 | email: z.string().email(), 11 | password: z.string().min(6), 12 | }); 13 | 14 | export interface LoginActionState { 15 | status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data'; 16 | } 17 | 18 | export const login = async ( 19 | _: LoginActionState, 20 | formData: FormData, 21 | ): Promise => { 22 | try { 23 | const validatedData = authFormSchema.parse({ 24 | email: formData.get('email'), 25 | password: formData.get('password'), 26 | }); 27 | 28 | await signIn('credentials', { 29 | email: validatedData.email, 30 | password: validatedData.password, 31 | redirect: false, 32 | }); 33 | 34 | return { status: 'success' }; 35 | } catch (error) { 36 | if (error instanceof z.ZodError) { 37 | return { status: 'invalid_data' }; 38 | } 39 | 40 | return { status: 'failed' }; 41 | } 42 | }; 43 | 44 | export interface RegisterActionState { 45 | status: 46 | | 'idle' 47 | | 'in_progress' 48 | | 'success' 49 | | 'failed' 50 | | 'user_exists' 51 | | 'invalid_data'; 52 | } 53 | 54 | export const register = async ( 55 | _: RegisterActionState, 56 | formData: FormData, 57 | ): Promise => { 58 | try { 59 | const validatedData = authFormSchema.parse({ 60 | email: formData.get('email'), 61 | password: formData.get('password'), 62 | }); 63 | 64 | const [user] = await getUser(validatedData.email); 65 | 66 | if (user) { 67 | return { status: 'user_exists' } as RegisterActionState; 68 | } 69 | await createUser(validatedData.email, validatedData.password); 70 | await signIn('credentials', { 71 | email: validatedData.email, 72 | password: validatedData.password, 73 | redirect: false, 74 | }); 75 | 76 | return { status: 'success' }; 77 | } catch (error) { 78 | if (error instanceof z.ZodError) { 79 | return { status: 'invalid_data' }; 80 | } 81 | 82 | return { status: 'failed' }; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /app/(auth)/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from '@/app/(auth)/auth'; 2 | -------------------------------------------------------------------------------- /app/(auth)/api/auth/guest/route.ts: -------------------------------------------------------------------------------- 1 | import { signIn } from '@/app/(auth)/auth'; 2 | import { isDevelopmentEnvironment } from '@/lib/constants'; 3 | import { getToken } from 'next-auth/jwt'; 4 | import { NextResponse } from 'next/server'; 5 | 6 | export async function GET(request: Request) { 7 | const { searchParams } = new URL(request.url); 8 | const redirectUrl = searchParams.get('redirectUrl') || '/'; 9 | 10 | const token = await getToken({ 11 | req: request, 12 | secret: process.env.AUTH_SECRET, 13 | secureCookie: !isDevelopmentEnvironment, 14 | }); 15 | 16 | if (token) { 17 | return NextResponse.redirect(new URL('/', request.url)); 18 | } 19 | 20 | return signIn('guest', { redirect: true, redirectTo: redirectUrl }); 21 | } 22 | -------------------------------------------------------------------------------- /app/(auth)/auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth'; 2 | 3 | export const authConfig = { 4 | pages: { 5 | signIn: '/login', 6 | newUser: '/', 7 | }, 8 | providers: [ 9 | // added later in auth.ts since it requires bcrypt which is only compatible with Node.js 10 | // while this file is also used in non-Node.js environments 11 | ], 12 | callbacks: {}, 13 | } satisfies NextAuthConfig; 14 | -------------------------------------------------------------------------------- /app/(auth)/auth.ts: -------------------------------------------------------------------------------- 1 | import { compare } from 'bcrypt-ts'; 2 | import NextAuth, { type DefaultSession } from 'next-auth'; 3 | import Credentials from 'next-auth/providers/credentials'; 4 | import { createGuestUser, getUser } from '@/lib/db/queries'; 5 | import { authConfig } from './auth.config'; 6 | import { DUMMY_PASSWORD } from '@/lib/constants'; 7 | import type { DefaultJWT } from 'next-auth/jwt'; 8 | 9 | export type UserType = 'guest' | 'regular'; 10 | 11 | declare module 'next-auth' { 12 | interface Session extends DefaultSession { 13 | user: { 14 | id: string; 15 | type: UserType; 16 | } & DefaultSession['user']; 17 | } 18 | 19 | interface User { 20 | id?: string; 21 | email?: string | null; 22 | type: UserType; 23 | } 24 | } 25 | 26 | declare module 'next-auth/jwt' { 27 | interface JWT extends DefaultJWT { 28 | id: string; 29 | type: UserType; 30 | } 31 | } 32 | 33 | export const { 34 | handlers: { GET, POST }, 35 | auth, 36 | signIn, 37 | signOut, 38 | } = NextAuth({ 39 | ...authConfig, 40 | providers: [ 41 | Credentials({ 42 | credentials: {}, 43 | async authorize({ email, password }: any) { 44 | const users = await getUser(email); 45 | 46 | if (users.length === 0) { 47 | await compare(password, DUMMY_PASSWORD); 48 | return null; 49 | } 50 | 51 | const [user] = users; 52 | 53 | if (!user.password) { 54 | await compare(password, DUMMY_PASSWORD); 55 | return null; 56 | } 57 | 58 | const passwordsMatch = await compare(password, user.password); 59 | 60 | if (!passwordsMatch) return null; 61 | 62 | return { ...user, type: 'regular' }; 63 | }, 64 | }), 65 | Credentials({ 66 | id: 'guest', 67 | credentials: {}, 68 | async authorize() { 69 | const [guestUser] = await createGuestUser(); 70 | return { ...guestUser, type: 'guest' }; 71 | }, 72 | }), 73 | ], 74 | callbacks: { 75 | async jwt({ token, user }) { 76 | if (user) { 77 | token.id = user.id as string; 78 | token.type = user.type; 79 | } 80 | 81 | return token; 82 | }, 83 | async session({ session, token }) { 84 | if (session.user) { 85 | session.user.id = token.id; 86 | session.user.type = token.type; 87 | } 88 | 89 | return session; 90 | }, 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useActionState, useEffect, useState } from 'react'; 6 | import { toast } from '@/components/toast'; 7 | 8 | import { AuthForm } from '@/components/auth-form'; 9 | import { SubmitButton } from '@/components/submit-button'; 10 | 11 | import { login, type LoginActionState } from '../actions'; 12 | import { useSession } from 'next-auth/react'; 13 | 14 | export default function Page() { 15 | const router = useRouter(); 16 | 17 | const [email, setEmail] = useState(''); 18 | const [isSuccessful, setIsSuccessful] = useState(false); 19 | 20 | const [state, formAction] = useActionState( 21 | login, 22 | { 23 | status: 'idle', 24 | }, 25 | ); 26 | 27 | const { update: updateSession } = useSession(); 28 | 29 | useEffect(() => { 30 | if (state.status === 'failed') { 31 | toast({ 32 | type: 'error', 33 | description: 'Invalid credentials!', 34 | }); 35 | } else if (state.status === 'invalid_data') { 36 | toast({ 37 | type: 'error', 38 | description: 'Failed validating your submission!', 39 | }); 40 | } else if (state.status === 'success') { 41 | setIsSuccessful(true); 42 | updateSession(); 43 | router.refresh(); 44 | } 45 | }, [state.status]); 46 | 47 | const handleSubmit = (formData: FormData) => { 48 | setEmail(formData.get('email') as string); 49 | formAction(formData); 50 | }; 51 | 52 | return ( 53 |
54 |
55 |
56 |

Sign In

57 |

58 | Use your email and password to sign in 59 |

60 |
61 | 62 | Sign in 63 |

64 | {"Don't have an account? "} 65 | 69 | Sign up 70 | 71 | {' for free.'} 72 |

73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useActionState, useEffect, useState } from 'react'; 6 | 7 | import { AuthForm } from '@/components/auth-form'; 8 | import { SubmitButton } from '@/components/submit-button'; 9 | 10 | import { register, type RegisterActionState } from '../actions'; 11 | import { toast } from '@/components/toast'; 12 | import { useSession } from 'next-auth/react'; 13 | 14 | export default function Page() { 15 | const router = useRouter(); 16 | 17 | const [email, setEmail] = useState(''); 18 | const [isSuccessful, setIsSuccessful] = useState(false); 19 | 20 | const [state, formAction] = useActionState( 21 | register, 22 | { 23 | status: 'idle', 24 | }, 25 | ); 26 | 27 | const { update: updateSession } = useSession(); 28 | 29 | useEffect(() => { 30 | if (state.status === 'user_exists') { 31 | toast({ type: 'error', description: 'Account already exists!' }); 32 | } else if (state.status === 'failed') { 33 | toast({ type: 'error', description: 'Failed to create account!' }); 34 | } else if (state.status === 'invalid_data') { 35 | toast({ 36 | type: 'error', 37 | description: 'Failed validating your submission!', 38 | }); 39 | } else if (state.status === 'success') { 40 | toast({ type: 'success', description: 'Account created successfully!' }); 41 | 42 | setIsSuccessful(true); 43 | updateSession(); 44 | router.refresh(); 45 | } 46 | }, [state]); 47 | 48 | const handleSubmit = (formData: FormData) => { 49 | setEmail(formData.get('email') as string); 50 | formAction(formData); 51 | }; 52 | 53 | return ( 54 |
55 |
56 |
57 |

Sign Up

58 |

59 | Create an account with your email and password 60 |

61 |
62 | 63 | Sign Up 64 |

65 | {'Already have an account? '} 66 | 70 | Sign in 71 | 72 | {' instead.'} 73 |

74 |
75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /app/(chat)/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { generateText, type UIMessage } from 'ai'; 4 | import { cookies } from 'next/headers'; 5 | import { 6 | deleteMessagesByChatIdAfterTimestamp, 7 | getMessageById, 8 | updateChatVisiblityById, 9 | } from '@/lib/db/queries'; 10 | import type { VisibilityType } from '@/components/visibility-selector'; 11 | import { myProvider } from '@/lib/ai/providers'; 12 | 13 | export async function saveChatModelAsCookie(model: string) { 14 | const cookieStore = await cookies(); 15 | cookieStore.set('chat-model', model); 16 | } 17 | 18 | export async function generateTitleFromUserMessage({ 19 | message, 20 | }: { 21 | message: UIMessage; 22 | }) { 23 | const { text: title } = await generateText({ 24 | model: myProvider.languageModel('title-model'), 25 | system: `\n 26 | - you will generate a short title based on the first message a user begins a conversation with 27 | - ensure it is not more than 80 characters long 28 | - the title should be a summary of the user's message 29 | - do not use quotes or colons`, 30 | prompt: JSON.stringify(message), 31 | }); 32 | 33 | return title; 34 | } 35 | 36 | export async function deleteTrailingMessages({ id }: { id: string }) { 37 | const [message] = await getMessageById({ id }); 38 | 39 | await deleteMessagesByChatIdAfterTimestamp({ 40 | chatId: message.chatId, 41 | timestamp: message.createdAt, 42 | }); 43 | } 44 | 45 | export async function updateChatVisibility({ 46 | chatId, 47 | visibility, 48 | }: { 49 | chatId: string; 50 | visibility: VisibilityType; 51 | }) { 52 | await updateChatVisiblityById({ chatId, visibility }); 53 | } 54 | -------------------------------------------------------------------------------- /app/(chat)/api/chat/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const textPartSchema = z.object({ 4 | text: z.string().min(1).max(2000), 5 | type: z.enum(['text']), 6 | }); 7 | 8 | export const postRequestBodySchema = z.object({ 9 | id: z.string().uuid(), 10 | message: z.object({ 11 | id: z.string().uuid(), 12 | createdAt: z.coerce.date(), 13 | role: z.enum(['user']), 14 | content: z.string().min(1).max(2000), 15 | parts: z.array(textPartSchema), 16 | experimental_attachments: z 17 | .array( 18 | z.object({ 19 | url: z.string().url(), 20 | name: z.string().min(1).max(2000), 21 | contentType: z.enum(['image/png', 'image/jpg', 'image/jpeg']), 22 | }), 23 | ) 24 | .optional(), 25 | }), 26 | selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']), 27 | selectedVisibilityType: z.enum(['public', 'private']), 28 | }); 29 | 30 | export type PostRequestBody = z.infer; 31 | -------------------------------------------------------------------------------- /app/(chat)/api/document/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/app/(auth)/auth'; 2 | import type { ArtifactKind } from '@/components/artifact'; 3 | import { 4 | deleteDocumentsByIdAfterTimestamp, 5 | getDocumentsById, 6 | saveDocument, 7 | } from '@/lib/db/queries'; 8 | import { ChatSDKError } from '@/lib/errors'; 9 | 10 | export async function GET(request: Request) { 11 | const { searchParams } = new URL(request.url); 12 | const id = searchParams.get('id'); 13 | 14 | if (!id) { 15 | return new ChatSDKError( 16 | 'bad_request:api', 17 | 'Parameter id is missing', 18 | ).toResponse(); 19 | } 20 | 21 | const session = await auth(); 22 | 23 | if (!session?.user) { 24 | return new ChatSDKError('unauthorized:document').toResponse(); 25 | } 26 | 27 | const documents = await getDocumentsById({ id }); 28 | 29 | const [document] = documents; 30 | 31 | if (!document) { 32 | return new ChatSDKError('not_found:document').toResponse(); 33 | } 34 | 35 | if (document.userId !== session.user.id) { 36 | return new ChatSDKError('forbidden:document').toResponse(); 37 | } 38 | 39 | return Response.json(documents, { status: 200 }); 40 | } 41 | 42 | export async function POST(request: Request) { 43 | const { searchParams } = new URL(request.url); 44 | const id = searchParams.get('id'); 45 | 46 | if (!id) { 47 | return new ChatSDKError( 48 | 'bad_request:api', 49 | 'Parameter id is required.', 50 | ).toResponse(); 51 | } 52 | 53 | const session = await auth(); 54 | 55 | if (!session?.user) { 56 | return new ChatSDKError('not_found:document').toResponse(); 57 | } 58 | 59 | const { 60 | content, 61 | title, 62 | kind, 63 | }: { content: string; title: string; kind: ArtifactKind } = 64 | await request.json(); 65 | 66 | const documents = await getDocumentsById({ id }); 67 | 68 | if (documents.length > 0) { 69 | const [document] = documents; 70 | 71 | if (document.userId !== session.user.id) { 72 | return new ChatSDKError('forbidden:document').toResponse(); 73 | } 74 | } 75 | 76 | const document = await saveDocument({ 77 | id, 78 | content, 79 | title, 80 | kind, 81 | userId: session.user.id, 82 | }); 83 | 84 | return Response.json(document, { status: 200 }); 85 | } 86 | 87 | export async function DELETE(request: Request) { 88 | const { searchParams } = new URL(request.url); 89 | const id = searchParams.get('id'); 90 | const timestamp = searchParams.get('timestamp'); 91 | 92 | if (!id) { 93 | return new ChatSDKError( 94 | 'bad_request:api', 95 | 'Parameter id is required.', 96 | ).toResponse(); 97 | } 98 | 99 | if (!timestamp) { 100 | return new ChatSDKError( 101 | 'bad_request:api', 102 | 'Parameter timestamp is required.', 103 | ).toResponse(); 104 | } 105 | 106 | const session = await auth(); 107 | 108 | if (!session?.user) { 109 | return new ChatSDKError('unauthorized:document').toResponse(); 110 | } 111 | 112 | const documents = await getDocumentsById({ id }); 113 | 114 | const [document] = documents; 115 | 116 | if (document.userId !== session.user.id) { 117 | return new ChatSDKError('forbidden:document').toResponse(); 118 | } 119 | 120 | const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ 121 | id, 122 | timestamp: new Date(timestamp), 123 | }); 124 | 125 | return Response.json(documentsDeleted, { status: 200 }); 126 | } 127 | -------------------------------------------------------------------------------- /app/(chat)/api/files/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { put } from '@vercel/blob'; 2 | import { NextResponse } from 'next/server'; 3 | import { z } from 'zod'; 4 | 5 | import { auth } from '@/app/(auth)/auth'; 6 | 7 | // Use Blob instead of File since File is not available in Node.js environment 8 | const FileSchema = z.object({ 9 | file: z 10 | .instanceof(Blob) 11 | .refine((file) => file.size <= 5 * 1024 * 1024, { 12 | message: 'File size should be less than 5MB', 13 | }) 14 | // Update the file type based on the kind of files you want to accept 15 | .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), { 16 | message: 'File type should be JPEG or PNG', 17 | }), 18 | }); 19 | 20 | export async function POST(request: Request) { 21 | const session = await auth(); 22 | 23 | if (!session) { 24 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 25 | } 26 | 27 | if (request.body === null) { 28 | return new Response('Request body is empty', { status: 400 }); 29 | } 30 | 31 | try { 32 | const formData = await request.formData(); 33 | const file = formData.get('file') as Blob; 34 | 35 | if (!file) { 36 | return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }); 37 | } 38 | 39 | const validatedFile = FileSchema.safeParse({ file }); 40 | 41 | if (!validatedFile.success) { 42 | const errorMessage = validatedFile.error.errors 43 | .map((error) => error.message) 44 | .join(', '); 45 | 46 | return NextResponse.json({ error: errorMessage }, { status: 400 }); 47 | } 48 | 49 | // Get filename from formData since Blob doesn't have name property 50 | const filename = (formData.get('file') as File).name; 51 | const fileBuffer = await file.arrayBuffer(); 52 | 53 | try { 54 | const data = await put(`${filename}`, fileBuffer, { 55 | access: 'public', 56 | }); 57 | 58 | return NextResponse.json(data); 59 | } catch (error) { 60 | return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); 61 | } 62 | } catch (error) { 63 | return NextResponse.json( 64 | { error: 'Failed to process request' }, 65 | { status: 500 }, 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/(chat)/api/history/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/app/(auth)/auth'; 2 | import type { NextRequest } from 'next/server'; 3 | import { getChatsByUserId } from '@/lib/db/queries'; 4 | import { ChatSDKError } from '@/lib/errors'; 5 | 6 | export async function GET(request: NextRequest) { 7 | const { searchParams } = request.nextUrl; 8 | 9 | const limit = Number.parseInt(searchParams.get('limit') || '10'); 10 | const startingAfter = searchParams.get('starting_after'); 11 | const endingBefore = searchParams.get('ending_before'); 12 | 13 | if (startingAfter && endingBefore) { 14 | return new ChatSDKError( 15 | 'bad_request:api', 16 | 'Only one of starting_after or ending_before can be provided.', 17 | ).toResponse(); 18 | } 19 | 20 | const session = await auth(); 21 | 22 | if (!session?.user) { 23 | return new ChatSDKError('unauthorized:chat').toResponse(); 24 | } 25 | 26 | const chats = await getChatsByUserId({ 27 | id: session.user.id, 28 | limit, 29 | startingAfter, 30 | endingBefore, 31 | }); 32 | 33 | return Response.json(chats); 34 | } 35 | -------------------------------------------------------------------------------- /app/(chat)/api/suggestions/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/app/(auth)/auth'; 2 | import { getSuggestionsByDocumentId } from '@/lib/db/queries'; 3 | import { ChatSDKError } from '@/lib/errors'; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const documentId = searchParams.get('documentId'); 8 | 9 | if (!documentId) { 10 | return new ChatSDKError( 11 | 'bad_request:api', 12 | 'Parameter documentId is required.', 13 | ).toResponse(); 14 | } 15 | 16 | const session = await auth(); 17 | 18 | if (!session?.user) { 19 | return new ChatSDKError('unauthorized:suggestions').toResponse(); 20 | } 21 | 22 | const suggestions = await getSuggestionsByDocumentId({ 23 | documentId, 24 | }); 25 | 26 | const [suggestion] = suggestions; 27 | 28 | if (!suggestion) { 29 | return Response.json([], { status: 200 }); 30 | } 31 | 32 | if (suggestion.userId !== session.user.id) { 33 | return new ChatSDKError('forbidden:api').toResponse(); 34 | } 35 | 36 | return Response.json(suggestions, { status: 200 }); 37 | } 38 | -------------------------------------------------------------------------------- /app/(chat)/api/vote/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/app/(auth)/auth'; 2 | import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries'; 3 | import { ChatSDKError } from '@/lib/errors'; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const chatId = searchParams.get('chatId'); 8 | 9 | if (!chatId) { 10 | return new ChatSDKError( 11 | 'bad_request:api', 12 | 'Parameter chatId is required.', 13 | ).toResponse(); 14 | } 15 | 16 | const session = await auth(); 17 | 18 | if (!session?.user) { 19 | return new ChatSDKError('unauthorized:vote').toResponse(); 20 | } 21 | 22 | const chat = await getChatById({ id: chatId }); 23 | 24 | if (!chat) { 25 | return new ChatSDKError('not_found:chat').toResponse(); 26 | } 27 | 28 | if (chat.userId !== session.user.id) { 29 | return new ChatSDKError('forbidden:vote').toResponse(); 30 | } 31 | 32 | const votes = await getVotesByChatId({ id: chatId }); 33 | 34 | return Response.json(votes, { status: 200 }); 35 | } 36 | 37 | export async function PATCH(request: Request) { 38 | const { 39 | chatId, 40 | messageId, 41 | type, 42 | }: { chatId: string; messageId: string; type: 'up' | 'down' } = 43 | await request.json(); 44 | 45 | if (!chatId || !messageId || !type) { 46 | return new ChatSDKError( 47 | 'bad_request:api', 48 | 'Parameters chatId, messageId, and type are required.', 49 | ).toResponse(); 50 | } 51 | 52 | const session = await auth(); 53 | 54 | if (!session?.user) { 55 | return new ChatSDKError('unauthorized:vote').toResponse(); 56 | } 57 | 58 | const chat = await getChatById({ id: chatId }); 59 | 60 | if (!chat) { 61 | return new ChatSDKError('not_found:vote').toResponse(); 62 | } 63 | 64 | if (chat.userId !== session.user.id) { 65 | return new ChatSDKError('forbidden:vote').toResponse(); 66 | } 67 | 68 | await voteMessage({ 69 | chatId, 70 | messageId, 71 | type: type, 72 | }); 73 | 74 | return new Response('Message voted', { status: 200 }); 75 | } 76 | -------------------------------------------------------------------------------- /app/(chat)/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { notFound, redirect } from 'next/navigation'; 3 | 4 | import { auth } from '@/app/(auth)/auth'; 5 | import { Chat } from '@/components/chat'; 6 | import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; 7 | import { DataStreamHandler } from '@/components/data-stream-handler'; 8 | import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; 9 | import type { DBMessage } from '@/lib/db/schema'; 10 | import type { Attachment, UIMessage } from 'ai'; 11 | 12 | export default async function Page(props: { params: Promise<{ id: string }> }) { 13 | const params = await props.params; 14 | const { id } = params; 15 | const chat = await getChatById({ id }); 16 | 17 | if (!chat) { 18 | notFound(); 19 | } 20 | 21 | const session = await auth(); 22 | 23 | if (!session) { 24 | redirect('/api/auth/guest'); 25 | } 26 | 27 | if (chat.visibility === 'private') { 28 | if (!session.user) { 29 | return notFound(); 30 | } 31 | 32 | if (session.user.id !== chat.userId) { 33 | return notFound(); 34 | } 35 | } 36 | 37 | const messagesFromDb = await getMessagesByChatId({ 38 | id, 39 | }); 40 | 41 | function convertToUIMessages(messages: Array): Array { 42 | return messages.map((message) => ({ 43 | id: message.id, 44 | parts: message.parts as UIMessage['parts'], 45 | role: message.role as UIMessage['role'], 46 | // Note: content will soon be deprecated in @ai-sdk/react 47 | content: '', 48 | createdAt: message.createdAt, 49 | experimental_attachments: 50 | (message.attachments as Array) ?? [], 51 | })); 52 | } 53 | 54 | const cookieStore = await cookies(); 55 | const chatModelFromCookie = cookieStore.get('chat-model'); 56 | 57 | if (!chatModelFromCookie) { 58 | return ( 59 | <> 60 | 69 | 70 | 71 | ); 72 | } 73 | 74 | return ( 75 | <> 76 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/(chat)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | 3 | import { AppSidebar } from '@/components/app-sidebar'; 4 | import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; 5 | import { auth } from '../(auth)/auth'; 6 | import Script from 'next/script'; 7 | 8 | export const experimental_ppr = true; 9 | 10 | export default async function Layout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | const [session, cookieStore] = await Promise.all([auth(), cookies()]); 16 | const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true'; 17 | 18 | return ( 19 | <> 20 |