diff --git a/.env.example b/.env.example index b6a81a490..bb0e42b96 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ OPENAI_API_KEY=**** # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` AUTH_SECRET=**** +/* + * The following keys below are automatically created and + * added to your environment when you deploy on vercel + */ + # Instructions to create kv database here: https://vercel.com/docs/storage/vercel-blob BLOB_READ_WRITE_TOKEN=**** diff --git a/.eslintrc.json b/.eslintrc.json index ed92fcc14..5f4cbd88a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,10 +2,14 @@ "extends": [ "next/core-web-vitals", "plugin:import/recommended", - "plugin:import/typescript" + "plugin:import/typescript", + "prettier", + "plugin:tailwindcss/recommended" ], - "plugins": ["import"], + "plugins": ["import", "tailwindcss"], "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off", "import/order": [ "error", { @@ -33,5 +37,5 @@ } } }, - "ignorePatterns": ["**/shadcn/**"] + "ignorePatterns": ["**/components/ui/**"] } diff --git a/README.md b/README.md index 79a670107..d55369c6a 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,47 @@ - Next.js 14 and App Router-ready AI chatbot. + Next.js 14 and App Router-ready AI chatbot.

Next.js AI Chatbot

- An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Vercel KV. + An Open-Source AI Chatbot Template Built With Next.js and the AI SDK by Vercel.

Features · Model Providers · Deploy Your Own · - Running locally · - Authors + Running locally


## Features - [Next.js](https://nextjs.org) App Router -- React Server Components (RSCs), Suspense, and Server Actions -- [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI -- Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain + - Advanced routing for seamless navigation and performance + - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance +- [AI SDK](https://sdk.vercel.ai/docs) + - Unified API for generating text, structured objects, and tool calls with LLMs + - Hooks for building dynamic chat and generative user interfaces + - Supports OpenAI (default), Anthropic, Cohere, and other model providers - [shadcn/ui](https://ui.shadcn.com) - Styling with [Tailwind CSS](https://tailwindcss.com) - - [Radix UI](https://radix-ui.com) for headless component primitives - - Icons from [Phosphor Icons](https://phosphoricons.com) -- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) -- [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication + - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility +- Data Persistence + - [Vercel Postgres powered by Neon](https://vercel.com/storage/postgres) for saving chat history and user data + - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage +- [NextAuth.js](https://github.com/nextauthjs/next-auth) + - Simple and secure authentication ## Model Providers -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. +This template ships with OpenAI `gpt-4o` as the default. 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. ## Deploy Your Own You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"}]) - -## Creating a KV Database Instance - -Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it. - -Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. +[![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,OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI%20Chatbot&demo-description=An%20Open-Source%20AI%20Chatbot%20Template%20Built%20With%20Next.js%20and%20the%20AI%20SDK%20by%20Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&stores=[{%22type%22:%22postgres%22},{%22type%22:%22blob%22}]) ## Running locally @@ -61,11 +59,3 @@ pnpm dev ``` Your app template should now be running on [localhost:3000](http://localhost:3000/). - -## Authors - -This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: - -- Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) -- Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) -- shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index db6db482d..d2e26bc9c 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -1,11 +1,18 @@ "use server"; +import { z } from "zod"; + import { createUser, getUser } from "@/db/queries"; import { signIn } from "./auth"; +const authFormSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}); + export interface LoginActionState { - status: "idle" | "in_progress" | "success" | "failed"; + status: "idle" | "in_progress" | "success" | "failed" | "invalid_data"; } export const login = async ( @@ -13,37 +20,66 @@ export const login = async ( formData: FormData, ): Promise => { try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("password"), + }); + await signIn("credentials", { - email: formData.get("email") as string, - password: formData.get("password") as string, + email: validatedData.email, + password: validatedData.password, redirect: false, }); - return { status: "success" } as LoginActionState; - } catch { - return { status: "failed" } as LoginActionState; + return { status: "success" }; + } catch (error) { + if (error instanceof z.ZodError) { + return { status: "invalid_data" }; + } + + return { status: "failed" }; } }; export interface RegisterActionState { - status: "idle" | "in_progress" | "success" | "failed" | "user_exists"; + status: + | "idle" + | "in_progress" + | "success" + | "failed" + | "user_exists" + | "invalid_data"; } -export const register = async (_: RegisterActionState, formData: FormData) => { - let email = formData.get("email") as string; - let password = formData.get("password") as string; - let user = await getUser(email); - - if (user.length > 0) { - return { status: "user_exists" } as RegisterActionState; - } else { - await createUser(email, password); - await signIn("credentials", { - email, - password, - redirect: false, +export const register = async ( + _: RegisterActionState, + formData: FormData, +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("password"), }); - return { status: "success" } as RegisterActionState; + let [user] = await getUser(validatedData.email); + + if (user) { + return { status: "user_exists" } as RegisterActionState; + } else { + await createUser(validatedData.email, validatedData.password); + await signIn("credentials", { + email: validatedData.email, + password: validatedData.password, + redirect: false, + }); + + return { status: "success" }; + } + } catch (error) { + if (error instanceof z.ZodError) { + return { status: "invalid_data" }; + } + + return { status: "failed" }; } }; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index ec804601f..e7a25e18d 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -1,13 +1,11 @@ import { compare } from "bcrypt-ts"; -import NextAuth, { User , Session } from "next-auth"; +import NextAuth, { User, Session } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { getUser } from "@/db/queries"; import { authConfig } from "./auth.config"; - - interface ExtendedSession extends Session { user: User; } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 234846e19..f3a1cb4cb 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,18 +2,19 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useActionState, useEffect } from "react"; +import { useActionState, useEffect, useState } from "react"; import { toast } from "sonner"; -import { Form } from "@/components/form"; -import { SubmitButton } from "@/components/submit-button"; +import { AuthForm } from "@/components/custom/auth-form"; +import { SubmitButton } from "@/components/custom/submit-button"; import { login, LoginActionState } from "../actions"; - export default function Page() { const router = useRouter(); + const [email, setEmail] = useState(""); + const [state, formAction] = useActionState( login, { @@ -24,11 +25,18 @@ export default function Page() { useEffect(() => { if (state.status === "failed") { toast.error("Invalid credentials!"); + } else if (state.status === "invalid_data") { + toast.error("Failed validating your submission!"); } else if (state.status === "success") { router.refresh(); } }, [state.status, router]); + const handleSubmit = (formData: FormData) => { + setEmail(formData.get("email") as string); + formAction(formData); + }; + return (
@@ -38,7 +46,7 @@ export default function Page() { Use your email and password to sign in

-
+ Sign in

{"Don't have an account? "} @@ -50,7 +58,7 @@ export default function Page() { {" for free."}

- +
); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 64b14352f..6ce4083d9 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -2,16 +2,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useActionState, useEffect } from "react"; +import { useActionState, useEffect, useState } from "react"; import { toast } from "sonner"; -import { Form } from "@/components/form"; -import { SubmitButton } from "@/components/submit-button"; +import { AuthForm } from "@/components/custom/auth-form"; +import { SubmitButton } from "@/components/custom/submit-button"; import { register, RegisterActionState } from "../actions"; export default function Page() { const router = useRouter(); + + const [email, setEmail] = useState(""); const [state, formAction] = useActionState( register, { @@ -24,12 +26,19 @@ export default function Page() { toast.error("Account already exists"); } else if (state.status === "failed") { toast.error("Failed to create account"); + } else if (state.status === "invalid_data") { + toast.error("Failed validating your submission!"); } else if (state.status === "success") { toast.success("Account created successfully"); router.refresh(); } }, [state, router]); + const handleSubmit = (formData: FormData) => { + setEmail(formData.get("email") as string); + formAction(formData); + }; + return (
@@ -39,7 +48,7 @@ export default function Page() { Create an account with your email and password

-
+ Sign Up

{"Already have an account? "} @@ -51,7 +60,7 @@ export default function Page() { {" instead."}

- +
); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index a6cffe3df..827d5b39d 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,4 +1,4 @@ -import { convertToCoreMessages, streamText } from "ai"; +import { convertToCoreMessages, Message, streamText } from "ai"; import { z } from "zod"; import { customModel } from "@/ai"; @@ -6,7 +6,8 @@ import { auth } from "@/app/(auth)/auth"; import { deleteChatById, getChatById, saveChat } from "@/db/queries"; export async function POST(request: Request) { - const { id, messages, selectedFilePathnames } = await request.json(); + const { id, messages }: { id: string; messages: Array } = + await request.json(); const session = await auth(); @@ -17,11 +18,6 @@ export async function POST(request: Request) { system: "you are a friendly assistant! keep your responses concise and helpful.", messages: coreMessages, - experimental_providerMetadata: { - files: { - selection: selectedFilePathnames, - }, - }, maxSteps: 5, tools: { getWeather: { @@ -42,11 +38,15 @@ export async function POST(request: Request) { }, onFinish: async ({ responseMessages }) => { if (session && session.user && session.user.id) { - await saveChat({ - id, - messages: [...coreMessages, ...responseMessages], - userId: session.user.id, - }); + try { + await saveChat({ + id, + messages: [...coreMessages, ...responseMessages], + userId: session.user.id, + }); + } catch (error) { + console.error("Failed to save chat"); + } } }, experimental_telemetry: { diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index b7f177d87..f4f6382af 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -1,8 +1,24 @@ import { put } from "@vercel/blob"; import { NextResponse } from "next/server"; +import { z } from "zod"; // import { auth } from "@/app/(auth)/auth"; +const FileSchema = z.object({ + file: z + .instanceof(File) + .refine((file) => file.size <= 5 * 1024 * 1024, { + message: "File size should be less than 5MB", + }) + .refine( + (file) => + ["image/jpeg", "image/png", "application/pdf"].includes(file.type), + { + message: "File type should be JPEG, PNG, or PDF", + }, + ), +}); + export async function POST(request: Request) { // const session = await auth(); @@ -22,6 +38,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } + const validatedFile = FileSchema.safeParse({ file }); + + if (!validatedFile.success) { + const errorMessage = validatedFile.error.errors + .map((error) => error.message) + .join(", "); + + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + const filename = file.name; const fileBuffer = await file.arrayBuffer(); diff --git a/app/(chat)/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx similarity index 96% rename from app/(chat)/[id]/page.tsx rename to app/(chat)/chat/[id]/page.tsx index 838b318b9..862b6bd0b 100644 --- a/app/(chat)/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -2,10 +2,10 @@ import { CoreMessage, CoreToolMessage, Message, ToolInvocation } from "ai"; import { notFound } from "next/navigation"; import { auth } from "@/app/(auth)/auth"; -import { Chat as PreviewChat } from "@/components/chat"; +import { Chat as PreviewChat } from "@/components/custom/chat"; import { getChatById } from "@/db/queries"; import { Chat } from "@/db/schema"; -import { generateUUID } from "@/utils/functions"; +import { generateUUID } from "@/lib/utils"; function addToolMessageToChat({ toolMessage, diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 28ae1f8a9..2b8d6d5e9 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,5 +1,5 @@ -import { Chat } from "@/components/chat"; -import { generateUUID } from "@/utils/functions"; +import { Chat } from "@/components/custom/chat"; +import { generateUUID } from "@/lib/utils"; import { auth } from "../(auth)/auth"; diff --git a/app/layout.tsx b/app/layout.tsx index 5b906eb35..73e2a3871 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,12 @@ import { Metadata } from "next"; import { Toaster } from "sonner"; -import "./globals.css"; -import { Navbar } from "@/components/navbar"; -import { ThemeProvider } from "@/components/theme-provider"; +import { Navbar } from "@/components/custom/navbar"; +import { ThemeProvider } from "@/components/custom/theme-provider"; import { KasadaClient } from "@/utils/kasada/kasada-client"; +import "./globals.css"; + export const metadata: Metadata = { metadataBase: new URL("https://chat.vercel.ai"), title: "Next.js Chatbot Template", diff --git a/components.json b/components.json index 4736b1cb6..1e961d331 100644 --- a/components.json +++ b/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "style": "new-york", "rsc": true, "tsx": true, "tailwind": { @@ -12,9 +12,9 @@ }, "aliases": { "components": "@/components", - "utils": "@/utils/shadcn/functions", - "ui": "@/components/shadcn", - "lib": "@/utils", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", "hooks": "@/hooks" } -} +} \ No newline at end of file diff --git a/components/form.tsx b/components/custom/auth-form.tsx similarity index 84% rename from components/form.tsx rename to components/custom/auth-form.tsx index 7f130aa8b..08610ffed 100644 --- a/components/form.tsx +++ b/components/custom/auth-form.tsx @@ -1,12 +1,14 @@ -import { Input } from "./shadcn/input"; -import { Label } from "./shadcn/label"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; -export function Form({ +export function AuthForm({ action, children, + defaultEmail = "", }: { action: any; children: React.ReactNode; + defaultEmail?: string; }) { return (
@@ -26,6 +28,7 @@ export function Form({ placeholder="user@acme.com" autoComplete="email" required + defaultValue={defaultEmail} />